thingsboard-aplcache

Merge pull request #135 from thingsboard/master Merge 1.2.3

5/9/2017 10:45:24 AM

Changes

.gitignore 1(+1 -0)

common/pom.xml 2(+1 -1)

dao/pom.xml 2(+1 -1)

pom.xml 2(+1 -1)

README.md 14(+7 -7)

tools/pom.xml 38(+1 -37)

ui/package.json 4(+3 -1)

ui/pom.xml 2(+1 -1)

Details

.gitignore 1(+1 -0)

diff --git a/.gitignore b/.gitignore
index 6f6dd61..9039e8e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
+*.toDelete
 output/**
 *.class
 *~
diff --git a/application/.gitignore b/application/.gitignore
index 08eb0a0..b5246c6 100644
--- a/application/.gitignore
+++ b/application/.gitignore
@@ -1 +1,2 @@
-!bin/
\ No newline at end of file
+!bin/
+/bin/
diff --git a/application/pom.xml b/application/pom.xml
index 9fbaebd..f3fd1d0 100644
--- a/application/pom.xml
+++ b/application/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>thingsboard</artifactId>
     </parent>
     <groupId>org.thingsboard</groupId>
diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java
index d4c42d8..0c73470 100644
--- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java
@@ -51,13 +51,7 @@ import org.thingsboard.server.extensions.api.plugins.msg.ToDeviceRpcRequestBody;
 import org.thingsboard.server.extensions.api.plugins.msg.ToDeviceRpcRequestPluginMsg;
 import org.thingsboard.server.extensions.api.plugins.msg.ToPluginRpcResponseDeviceMsg;
 
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.UUID;
+import java.util.*;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeoutException;
 import java.util.function.Consumer;
@@ -205,25 +199,21 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso
 
     void processAttributesUpdate(ActorContext context, DeviceAttributesEventNotificationMsg msg) {
         refreshAttributes(msg);
-        Set<AttributeKey> keys = msg.getDeletedKeys();
         if (attributeSubscriptions.size() > 0) {
             ToDeviceMsg notification = null;
             if (msg.isDeleted()) {
-                List<AttributeKey> sharedKeys = keys.stream()
+                List<AttributeKey> sharedKeys = msg.getDeletedKeys().stream()
                         .filter(key -> DataConstants.SHARED_SCOPE.equals(key.getScope()))
                         .collect(Collectors.toList());
                 notification = new AttributesUpdateNotification(BasicAttributeKVMsg.fromDeleted(sharedKeys));
             } else {
-                List<AttributeKvEntry> attributes = keys.stream()
-                        .filter(key -> DataConstants.SHARED_SCOPE.equals(key.getScope()))
-                        .map(key -> deviceAttributes.getServerPublicAttribute(key.getAttributeKey()))
-                        .filter(Optional::isPresent)
-                        .map(Optional::get)
-                        .collect(Collectors.toList());
-                if (attributes.size() > 0) {
-                    notification = new AttributesUpdateNotification(BasicAttributeKVMsg.fromShared(attributes));
-                } else {
-                    logger.debug("[{}] No public server side attributes changed!", deviceId);
+                if (DataConstants.SHARED_SCOPE.equals(msg.getScope())) {
+                    List<AttributeKvEntry> attributes = new ArrayList<>(msg.getValues());
+                    if (attributes.size() > 0) {
+                        notification = new AttributesUpdateNotification(BasicAttributeKVMsg.fromShared(attributes));
+                    } else {
+                        logger.debug("[{}] No public server side attributes changed!", deviceId);
+                    }
                 }
             }
             if (notification != null) {
diff --git a/application/src/main/java/org/thingsboard/server/config/MvcCorsProperties.java b/application/src/main/java/org/thingsboard/server/config/MvcCorsProperties.java
new file mode 100644
index 0000000..62b4ec2
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/config/MvcCorsProperties.java
@@ -0,0 +1,45 @@
+/**
+ * Copyright © 2016-2017 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.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Created by yyh on 2017/5/2.
+ * CORS configuration
+ */
+@Configuration
+@ConfigurationProperties(prefix = "spring.mvc.cors")
+public class MvcCorsProperties {
+
+    private Map<String, CorsConfiguration> mappings = new HashMap<>();
+
+    public MvcCorsProperties() {
+    }
+
+    public Map<String, CorsConfiguration> getMappings() {
+        return mappings;
+    }
+
+    public void setMappings(Map<String, CorsConfiguration> mappings) {
+        this.mappings = mappings;
+    }
+}
diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
index a7918e0..cdca099 100644
--- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
+++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
@@ -18,7 +18,9 @@ package org.thingsboard.server.config;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
 import org.springframework.boot.autoconfigure.security.SecurityProperties;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.annotation.Order;
@@ -34,11 +36,15 @@ import org.springframework.security.web.authentication.AuthenticationFailureHand
 import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
 import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
 import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.web.cors.CorsUtils;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
 import org.thingsboard.server.exception.ThingsboardErrorResponseHandler;
 import org.thingsboard.server.service.security.auth.rest.RestAuthenticationProvider;
 import org.thingsboard.server.service.security.auth.rest.RestLoginProcessingFilter;
 import org.thingsboard.server.service.security.auth.jwt.*;
 import org.thingsboard.server.service.security.auth.jwt.extractor.TokenExtractor;
+import org.thingsboard.server.service.security.auth.rest.RestPublicLoginProcessingFilter;
 
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -56,6 +62,7 @@ public class ThingsboardSecurityConfiguration extends WebSecurityConfigurerAdapt
     public static final String WEBJARS_ENTRY_POINT = "/webjars/**";
     public static final String DEVICE_API_ENTRY_POINT = "/api/v1/**";
     public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login";
+    public static final String PUBLIC_LOGIN_ENTRY_POINT = "/api/auth/login/public";
     public static final String TOKEN_REFRESH_ENTRY_POINT = "/api/auth/token";
     public static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[] {"/index.html", "/static/**", "/api/noauth/**", "/webjars/**"};
     public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**";
@@ -88,9 +95,17 @@ public class ThingsboardSecurityConfiguration extends WebSecurityConfigurerAdapt
     }
 
     @Bean
+    protected RestPublicLoginProcessingFilter buildRestPublicLoginProcessingFilter() throws Exception {
+        RestPublicLoginProcessingFilter filter = new RestPublicLoginProcessingFilter(PUBLIC_LOGIN_ENTRY_POINT, successHandler, failureHandler, objectMapper);
+        filter.setAuthenticationManager(this.authenticationManager);
+        return filter;
+    }
+
+    @Bean
     protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() throws Exception {
         List<String> pathsToSkip = new ArrayList(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS));
-        pathsToSkip.addAll(Arrays.asList(WS_TOKEN_BASED_AUTH_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, WEBJARS_ENTRY_POINT));
+        pathsToSkip.addAll(Arrays.asList(WS_TOKEN_BASED_AUTH_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT,
+                PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, WEBJARS_ENTRY_POINT));
         SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT);
         JwtTokenAuthenticationProcessingFilter filter
                 = new JwtTokenAuthenticationProcessingFilter(failureHandler, jwtHeaderTokenExtractor, matcher);
@@ -136,6 +151,8 @@ public class ThingsboardSecurityConfiguration extends WebSecurityConfigurerAdapt
     protected void configure(HttpSecurity http) throws Exception {
         http.headers().cacheControl().disable().frameOptions().disable()
                 .and()
+                .cors()
+                .and()
                 .csrf().disable()
                 .exceptionHandling()
                 .and()
@@ -146,6 +163,7 @@ public class ThingsboardSecurityConfiguration extends WebSecurityConfigurerAdapt
                 .antMatchers(WEBJARS_ENTRY_POINT).permitAll() // Webjars
                 .antMatchers(DEVICE_API_ENTRY_POINT).permitAll() // Device HTTP Transport API
                 .antMatchers(FORM_BASED_LOGIN_ENTRY_POINT).permitAll() // Login end-point
+                .antMatchers(PUBLIC_LOGIN_ENTRY_POINT).permitAll() // Public login end-point
                 .antMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll() // Token refresh end-point
                 .antMatchers(NON_TOKEN_BASED_AUTH_ENTRY_POINTS).permitAll() // static resources, user activation and password reset end-points
                 .and()
@@ -156,8 +174,22 @@ public class ThingsboardSecurityConfiguration extends WebSecurityConfigurerAdapt
                 .exceptionHandling().accessDeniedHandler(restAccessDeniedHandler)
                 .and()
                 .addFilterBefore(buildRestLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
+                .addFilterBefore(buildRestPublicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
                 .addFilterBefore(buildJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
                 .addFilterBefore(buildRefreshTokenProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
                 .addFilterBefore(buildWsJwtTokenAuthenticationProcessingFilter(), UsernamePasswordAuthenticationFilter.class);
     }
+
+
+    @Bean
+    @ConditionalOnMissingBean(CorsFilter.class)
+    public CorsFilter corsFilter(@Autowired MvcCorsProperties mvcCorsProperties) {
+        if (mvcCorsProperties.getMappings().size() == 0) {
+            return new CorsFilter(new UrlBasedCorsConfigurationSource());
+        } else {
+            UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+            source.setCorsConfigurations(mvcCorsProperties.getMappings());
+            return new CorsFilter(source);
+        }
+    }
 }
diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java
index 5ef52c2..d06f2be 100644
--- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java
@@ -36,6 +36,7 @@ import org.thingsboard.server.exception.ThingsboardException;
 import org.thingsboard.server.service.mail.MailService;
 import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRepository;
 import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.model.UserPrincipal;
 import org.thingsboard.server.service.security.model.token.JwtToken;
 import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
 
@@ -167,7 +168,8 @@ public class AuthController extends BaseController {
             String encodedPassword = passwordEncoder.encode(password);
             UserCredentials credentials = userService.activateUserCredentials(activateToken, encodedPassword);
             User user = userService.findUserById(credentials.getUserId());
-            SecurityUser securityUser = new SecurityUser(user, credentials.isEnabled());
+            UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail());
+            SecurityUser securityUser = new SecurityUser(user, credentials.isEnabled(), principal);
             String baseUrl = constructBaseUrl(request);
             String loginUrl = String.format("%s/login", baseUrl);
             String email = user.getEmail();
@@ -201,7 +203,8 @@ public class AuthController extends BaseController {
                 userCredentials.setResetToken(null);
                 userCredentials = userService.saveUserCredentials(userCredentials);
                 User user = userService.findUserById(userCredentials.getUserId());
-                SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled());
+                UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail());
+                SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), principal);
                 String baseUrl = constructBaseUrl(request);
                 String loginUrl = String.format("%s/login", baseUrl);
                 String email = user.getEmail();
diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java
index 4d7c7cd..51bc452 100644
--- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java
@@ -15,6 +15,9 @@
  */
 package org.thingsboard.server.controller;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import org.springframework.http.HttpStatus;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
@@ -42,6 +45,28 @@ public class CustomerController extends BaseController {
         }
     }
 
+    @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
+    @RequestMapping(value = "/customer/{customerId}/shortInfo", method = RequestMethod.GET)
+    @ResponseBody
+    public JsonNode getShortCustomerInfoById(@PathVariable("customerId") String strCustomerId) throws ThingsboardException {
+        checkParameter("customerId", strCustomerId);
+        try {
+            CustomerId customerId = new CustomerId(toUUID(strCustomerId));
+            Customer customer = checkCustomerId(customerId);
+            ObjectMapper objectMapper = new ObjectMapper();
+            ObjectNode infoObject = objectMapper.createObjectNode();
+            infoObject.put("title", customer.getTitle());
+            boolean isPublic = false;
+            if (customer.getAdditionalInfo() != null && customer.getAdditionalInfo().has("isPublic")) {
+                isPublic = customer.getAdditionalInfo().get("isPublic").asBoolean();
+            }
+            infoObject.put("isPublic", isPublic);
+            return infoObject;
+        } catch (Exception e) {
+            throw handleException(e);
+        }
+    }
+
     @PreAuthorize("hasAuthority('TENANT_ADMIN')")
     @RequestMapping(value = "/customer", method = RequestMethod.POST)
     @ResponseBody 
diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java
index d72f025..3812610 100644
--- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java
@@ -18,6 +18,7 @@ package org.thingsboard.server.controller;
 import org.springframework.http.HttpStatus;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
+import org.thingsboard.server.common.data.Customer;
 import org.thingsboard.server.common.data.Dashboard;
 import org.thingsboard.server.common.data.DashboardInfo;
 import org.thingsboard.server.common.data.id.CustomerId;
@@ -117,6 +118,21 @@ public class DashboardController extends BaseController {
     }
 
     @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+    @RequestMapping(value = "/customer/public/dashboard/{dashboardId}", method = RequestMethod.POST)
+    @ResponseBody
+    public Dashboard assignDashboardToPublicCustomer(@PathVariable("dashboardId") String strDashboardId) throws ThingsboardException {
+        checkParameter("dashboardId", strDashboardId);
+        try {
+            DashboardId dashboardId = new DashboardId(toUUID(strDashboardId));
+            Dashboard dashboard = checkDashboardId(dashboardId);
+            Customer publicCustomer = customerService.findOrCreatePublicCustomer(dashboard.getTenantId());
+            return checkNotNull(dashboardService.assignDashboardToCustomer(dashboardId, publicCustomer.getId()));
+        } catch (Exception e) {
+            throw handleException(e);
+        }
+    }
+
+    @PreAuthorize("hasAuthority('TENANT_ADMIN')")
     @RequestMapping(value = "/tenant/dashboards", params = { "limit" }, method = RequestMethod.GET)
     @ResponseBody
     public TextPageData<DashboardInfo> getTenantDashboards(
diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java
index b08a964..bebab8b 100644
--- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java
+++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java
@@ -19,6 +19,7 @@ import com.google.common.util.concurrent.ListenableFuture;
 import org.springframework.http.HttpStatus;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
+import org.thingsboard.server.common.data.Customer;
 import org.thingsboard.server.common.data.Device;
 import org.thingsboard.server.common.data.id.CustomerId;
 import org.thingsboard.server.common.data.id.DeviceId;
@@ -117,6 +118,21 @@ public class DeviceController extends BaseController {
         }
     }
 
+    @PreAuthorize("hasAuthority('TENANT_ADMIN')")
+    @RequestMapping(value = "/customer/public/device/{deviceId}", method = RequestMethod.POST)
+    @ResponseBody
+    public Device assignDeviceToPublicCustomer(@PathVariable("deviceId") String strDeviceId) throws ThingsboardException {
+        checkParameter("deviceId", strDeviceId);
+        try {
+            DeviceId deviceId = new DeviceId(toUUID(strDeviceId));
+            Device device = checkDeviceId(deviceId);
+            Customer publicCustomer = customerService.findOrCreatePublicCustomer(device.getTenantId());
+            return checkNotNull(deviceService.assignDeviceToCustomer(deviceId, publicCustomer.getId()));
+        } catch (Exception e) {
+            throw handleException(e);
+        }
+    }
+
     @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')")
     @RequestMapping(value = "/device/{deviceId}/credentials", method = RequestMethod.GET)
     @ResponseBody
diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java
index 5ba84bf..811f39f 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/RefreshTokenAuthenticationProvider.java
@@ -16,32 +16,40 @@
 package org.thingsboard.server.service.security.auth.jwt;
 
 import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.authentication.AuthenticationProvider;
-import org.springframework.security.authentication.DisabledException;
-import org.springframework.security.authentication.InsufficientAuthenticationException;
-import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.authentication.*;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
 import org.springframework.security.core.userdetails.UsernameNotFoundException;
 import org.springframework.stereotype.Component;
 import org.springframework.util.Assert;
+import org.thingsboard.server.common.data.Customer;
 import org.thingsboard.server.common.data.User;
+import org.thingsboard.server.common.data.id.CustomerId;
+import org.thingsboard.server.common.data.id.UUIDBased;
+import org.thingsboard.server.common.data.id.UserId;
+import org.thingsboard.server.common.data.security.Authority;
 import org.thingsboard.server.common.data.security.UserCredentials;
+import org.thingsboard.server.dao.customer.CustomerService;
 import org.thingsboard.server.dao.user.UserService;
 import org.thingsboard.server.service.security.auth.RefreshAuthenticationToken;
 import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.model.UserPrincipal;
 import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
 import org.thingsboard.server.service.security.model.token.RawAccessJwtToken;
 
+import java.util.UUID;
+
 @Component
 public class RefreshTokenAuthenticationProvider implements AuthenticationProvider {
 
     private final JwtTokenFactory tokenFactory;
     private final UserService userService;
+    private final CustomerService customerService;
 
     @Autowired
-    public RefreshTokenAuthenticationProvider(final UserService userService, final JwtTokenFactory tokenFactory) {
+    public RefreshTokenAuthenticationProvider(final UserService userService, final CustomerService customerService, final JwtTokenFactory tokenFactory) {
         this.userService = userService;
+        this.customerService = customerService;
         this.tokenFactory = tokenFactory;
     }
 
@@ -50,8 +58,18 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
         Assert.notNull(authentication, "No authentication data provided");
         RawAccessJwtToken rawAccessToken = (RawAccessJwtToken) authentication.getCredentials();
         SecurityUser unsafeUser = tokenFactory.parseRefreshToken(rawAccessToken);
+        UserPrincipal principal = unsafeUser.getUserPrincipal();
+        SecurityUser securityUser;
+        if (principal.getType() == UserPrincipal.Type.USER_NAME) {
+            securityUser = authenticateByUserId(unsafeUser.getId());
+        } else {
+            securityUser = authenticateByPublicId(principal.getValue());
+        }
+        return new RefreshAuthenticationToken(securityUser);
+    }
 
-        User user = userService.findUserById(unsafeUser.getId());
+    private SecurityUser authenticateByUserId(UserId userId) {
+        User user = userService.findUserById(userId);
         if (user == null) {
             throw new UsernameNotFoundException("User not found by refresh token");
         }
@@ -67,9 +85,44 @@ public class RefreshTokenAuthenticationProvider implements AuthenticationProvide
 
         if (user.getAuthority() == null) throw new InsufficientAuthenticationException("User has no authority assigned");
 
-        SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled());
+        UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.USER_NAME, user.getEmail());
 
-        return new RefreshAuthenticationToken(securityUser);
+        SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal);
+
+        return securityUser;
+    }
+
+    private SecurityUser authenticateByPublicId(String publicId) {
+        CustomerId customerId;
+        try {
+            customerId = new CustomerId(UUID.fromString(publicId));
+        } catch (Exception e) {
+            throw new BadCredentialsException("Refresh token is not valid");
+        }
+        Customer publicCustomer = customerService.findCustomerById(customerId);
+        if (publicCustomer == null) {
+            throw new UsernameNotFoundException("Public entity not found by refresh token");
+        }
+        boolean isPublic = false;
+        if (publicCustomer.getAdditionalInfo() != null && publicCustomer.getAdditionalInfo().has("isPublic")) {
+            isPublic = publicCustomer.getAdditionalInfo().get("isPublic").asBoolean();
+        }
+        if (!isPublic) {
+            throw new BadCredentialsException("Refresh token is not valid");
+        }
+        User user = new User(new UserId(UUIDBased.EMPTY));
+        user.setTenantId(publicCustomer.getTenantId());
+        user.setCustomerId(publicCustomer.getId());
+        user.setEmail(publicId);
+        user.setAuthority(Authority.CUSTOMER_USER);
+        user.setFirstName("Public");
+        user.setLastName("Public");
+
+        UserPrincipal userPrincipal = new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, publicId);
+
+        SecurityUser securityUser = new SecurityUser(user, true, userPrincipal);
+
+        return securityUser;
     }
 
     @Override
diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/PublicLoginRequest.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/PublicLoginRequest.java
new file mode 100644
index 0000000..54ef093
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/PublicLoginRequest.java
@@ -0,0 +1,34 @@
+/**
+ * Copyright © 2016-2017 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.service.security.auth.rest;
+
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class PublicLoginRequest {
+
+    private String publicId;
+
+    @JsonCreator
+    public PublicLoginRequest(@JsonProperty("publicId") String publicId) {
+        this.publicId = publicId;
+   }
+
+    public String getPublicId() {
+        return publicId;
+    }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java
index 686bb46..af10674 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationProvider.java
@@ -23,20 +23,31 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException;
 import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
 import org.springframework.stereotype.Component;
 import org.springframework.util.Assert;
+import org.thingsboard.server.common.data.Customer;
 import org.thingsboard.server.common.data.User;
+import org.thingsboard.server.common.data.id.CustomerId;
+import org.thingsboard.server.common.data.id.UUIDBased;
+import org.thingsboard.server.common.data.id.UserId;
+import org.thingsboard.server.common.data.security.Authority;
 import org.thingsboard.server.common.data.security.UserCredentials;
+import org.thingsboard.server.dao.customer.CustomerService;
 import org.thingsboard.server.dao.user.UserService;
 import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.model.UserPrincipal;
+
+import java.util.UUID;
 
 @Component
 public class RestAuthenticationProvider implements AuthenticationProvider {
 
     private final BCryptPasswordEncoder encoder;
     private final UserService userService;
+    private final CustomerService customerService;
 
     @Autowired
-    public RestAuthenticationProvider(final UserService userService, final BCryptPasswordEncoder encoder) {
+    public RestAuthenticationProvider(final UserService userService, final CustomerService customerService, final BCryptPasswordEncoder encoder) {
         this.userService = userService;
+        this.customerService = customerService;
         this.encoder = encoder;
     }
 
@@ -44,9 +55,23 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
     public Authentication authenticate(Authentication authentication) throws AuthenticationException {
         Assert.notNull(authentication, "No authentication data provided");
 
-        String username = (String) authentication.getPrincipal();
-        String password = (String) authentication.getCredentials();
+        Object principal = authentication.getPrincipal();
+        if (!(principal instanceof UserPrincipal)) {
+            throw new BadCredentialsException("Authentication Failed. Bad user principal.");
+        }
 
+        UserPrincipal userPrincipal =  (UserPrincipal) principal;
+        if (userPrincipal.getType() == UserPrincipal.Type.USER_NAME) {
+            String username = userPrincipal.getValue();
+            String password = (String) authentication.getCredentials();
+            return authenticateByUsernameAndPassword(userPrincipal, username, password);
+        } else {
+            String publicId = userPrincipal.getValue();
+            return authenticateByPublicId(userPrincipal, publicId);
+        }
+    }
+
+    private Authentication authenticateByUsernameAndPassword(UserPrincipal userPrincipal, String username, String password) {
         User user = userService.findUserByEmail(username);
         if (user == null) {
             throw new UsernameNotFoundException("User not found: " + username);
@@ -67,7 +92,38 @@ public class RestAuthenticationProvider implements AuthenticationProvider {
 
         if (user.getAuthority() == null) throw new InsufficientAuthenticationException("User has no authority assigned");
 
-        SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled());
+        SecurityUser securityUser = new SecurityUser(user, userCredentials.isEnabled(), userPrincipal);
+
+        return new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities());
+    }
+
+    private Authentication authenticateByPublicId(UserPrincipal userPrincipal, String publicId) {
+        CustomerId customerId;
+        try {
+            customerId = new CustomerId(UUID.fromString(publicId));
+        } catch (Exception e) {
+            throw new BadCredentialsException("Authentication Failed. Public Id is not valid.");
+        }
+        Customer publicCustomer = customerService.findCustomerById(customerId);
+        if (publicCustomer == null) {
+            throw new UsernameNotFoundException("Public entity not found: " + publicId);
+        }
+        boolean isPublic = false;
+        if (publicCustomer.getAdditionalInfo() != null && publicCustomer.getAdditionalInfo().has("isPublic")) {
+            isPublic = publicCustomer.getAdditionalInfo().get("isPublic").asBoolean();
+        }
+        if (!isPublic) {
+            throw new BadCredentialsException("Authentication Failed. Public Id is not valid.");
+        }
+        User user = new User(new UserId(UUIDBased.EMPTY));
+        user.setTenantId(publicCustomer.getTenantId());
+        user.setCustomerId(publicCustomer.getId());
+        user.setEmail(publicId);
+        user.setAuthority(Authority.CUSTOMER_USER);
+        user.setFirstName("Public");
+        user.setLastName("Public");
+
+        SecurityUser securityUser = new SecurityUser(user, true, userPrincipal);
 
         return new UsernamePasswordAuthenticationToken(securityUser, null, securityUser.getAuthorities());
     }
diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java
index d191905..c32b1f2 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestLoginProcessingFilter.java
@@ -29,6 +29,7 @@ import org.springframework.security.web.authentication.AbstractAuthenticationPro
 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
 import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
 import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException;
+import org.thingsboard.server.service.security.model.UserPrincipal;
 
 import javax.servlet.FilterChain;
 import javax.servlet.ServletException;
@@ -73,7 +74,9 @@ public class RestLoginProcessingFilter extends AbstractAuthenticationProcessingF
             throw new AuthenticationServiceException("Username or Password not provided");
         }
 
-        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginRequest.getUsername(), loginRequest.getPassword());
+        UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.USER_NAME, loginRequest.getUsername());
+
+        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(principal, loginRequest.getPassword());
 
         return this.getAuthenticationManager().authenticate(token);
     }
diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java
new file mode 100644
index 0000000..3a3b7cb
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestPublicLoginProcessingFilter.java
@@ -0,0 +1,96 @@
+/**
+ * Copyright © 2016-2017 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.thingsboard.server.service.security.auth.rest;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.HttpMethod;
+import org.springframework.security.authentication.AuthenticationServiceException;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException;
+import org.thingsboard.server.service.security.model.UserPrincipal;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+
+public class RestPublicLoginProcessingFilter extends AbstractAuthenticationProcessingFilter {
+    private static Logger logger = LoggerFactory.getLogger(RestPublicLoginProcessingFilter.class);
+
+    private final AuthenticationSuccessHandler successHandler;
+    private final AuthenticationFailureHandler failureHandler;
+
+    private final ObjectMapper objectMapper;
+
+    public RestPublicLoginProcessingFilter(String defaultProcessUrl, AuthenticationSuccessHandler successHandler,
+                                     AuthenticationFailureHandler failureHandler, ObjectMapper mapper) {
+        super(defaultProcessUrl);
+        this.successHandler = successHandler;
+        this.failureHandler = failureHandler;
+        this.objectMapper = mapper;
+    }
+
+    @Override
+    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
+            throws AuthenticationException, IOException, ServletException {
+        if (!HttpMethod.POST.name().equals(request.getMethod())) {
+            if(logger.isDebugEnabled()) {
+                logger.debug("Authentication method not supported. Request method: " + request.getMethod());
+            }
+            throw new AuthMethodNotSupportedException("Authentication method not supported");
+        }
+
+        PublicLoginRequest loginRequest;
+        try {
+            loginRequest = objectMapper.readValue(request.getReader(), PublicLoginRequest.class);
+        } catch (Exception e) {
+            throw new AuthenticationServiceException("Invalid public login request payload");
+        }
+
+        if (StringUtils.isBlank(loginRequest.getPublicId())) {
+            throw new AuthenticationServiceException("Public Id is not provided");
+        }
+
+        UserPrincipal principal = new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, loginRequest.getPublicId());
+
+        UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(principal, "");
+
+        return this.getAuthenticationManager().authenticate(token);
+    }
+
+    @Override
+    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
+                                            Authentication authResult) throws IOException, ServletException {
+        successHandler.onAuthenticationSuccess(request, response, authResult);
+    }
+
+    @Override
+    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
+                                              AuthenticationException failed) throws IOException, ServletException {
+        SecurityContextHolder.clearContext();
+        failureHandler.onAuthenticationFailure(request, response, failed);
+    }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java b/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java
index 2da3a97..0839695 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/model/SecurityUser.java
@@ -30,6 +30,7 @@ public class SecurityUser extends User {
 
     private Collection<GrantedAuthority> authorities;
     private boolean enabled;
+    private UserPrincipal userPrincipal;
 
     public SecurityUser() {
         super();
@@ -39,9 +40,10 @@ public class SecurityUser extends User {
         super(id);
     }
 
-    public SecurityUser(User user, boolean enabled) {
+    public SecurityUser(User user, boolean enabled, UserPrincipal userPrincipal) {
         super(user);
         this.enabled = enabled;
+        this.userPrincipal = userPrincipal;
     }
 
     public Collection<? extends GrantedAuthority> getAuthorities() {
@@ -57,8 +59,16 @@ public class SecurityUser extends User {
         return enabled;
     }
 
+    public UserPrincipal getUserPrincipal() {
+        return userPrincipal;
+    }
+
     public void setEnabled(boolean enabled) {
         this.enabled = enabled;
     }
 
+    public void setUserPrincipal(UserPrincipal userPrincipal) {
+        this.userPrincipal = userPrincipal;
+    }
+
 }
diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java
index 8ade253..20de6d8 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java
@@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.id.UserId;
 import org.thingsboard.server.common.data.security.Authority;
 import org.thingsboard.server.config.JwtSettings;
 import org.thingsboard.server.service.security.model.SecurityUser;
+import org.thingsboard.server.service.security.model.UserPrincipal;
 
 import java.util.Arrays;
 import java.util.List;
@@ -43,6 +44,7 @@ public class JwtTokenFactory {
     private static final String FIRST_NAME = "firstName";
     private static final String LAST_NAME = "lastName";
     private static final String ENABLED = "enabled";
+    private static final String IS_PUBLIC = "isPublic";
     private static final String TENANT_ID = "tenantId";
     private static final String CUSTOMER_ID = "customerId";
 
@@ -63,12 +65,15 @@ public class JwtTokenFactory {
         if (securityUser.getAuthority() == null)
             throw new IllegalArgumentException("User doesn't have any privileges");
 
-        Claims claims = Jwts.claims().setSubject(securityUser.getEmail());
+        UserPrincipal principal = securityUser.getUserPrincipal();
+        String subject = principal.getValue();
+        Claims claims = Jwts.claims().setSubject(subject);
         claims.put(SCOPES, securityUser.getAuthorities().stream().map(s -> s.getAuthority()).collect(Collectors.toList()));
         claims.put(USER_ID, securityUser.getId().getId().toString());
         claims.put(FIRST_NAME, securityUser.getFirstName());
         claims.put(LAST_NAME, securityUser.getLastName());
         claims.put(ENABLED, securityUser.isEnabled());
+        claims.put(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID);
         if (securityUser.getTenantId() != null) {
             claims.put(TENANT_ID, securityUser.getTenantId().getId().toString());
         }
@@ -104,6 +109,9 @@ public class JwtTokenFactory {
         securityUser.setFirstName(claims.get(FIRST_NAME, String.class));
         securityUser.setLastName(claims.get(LAST_NAME, String.class));
         securityUser.setEnabled(claims.get(ENABLED, Boolean.class));
+        boolean isPublic = claims.get(IS_PUBLIC, Boolean.class);
+        UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject);
+        securityUser.setUserPrincipal(principal);
         String tenantId = claims.get(TENANT_ID, String.class);
         if (tenantId != null) {
             securityUser.setTenantId(new TenantId(UUID.fromString(tenantId)));
@@ -123,9 +131,11 @@ public class JwtTokenFactory {
 
         DateTime currentTime = new DateTime();
 
-        Claims claims = Jwts.claims().setSubject(securityUser.getEmail());
+        UserPrincipal principal = securityUser.getUserPrincipal();
+        Claims claims = Jwts.claims().setSubject(principal.getValue());
         claims.put(SCOPES, Arrays.asList(Authority.REFRESH_TOKEN.name()));
         claims.put(USER_ID, securityUser.getId().getId().toString());
+        claims.put(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID);
 
         String token = Jwts.builder()
                 .setClaims(claims)
@@ -150,8 +160,10 @@ public class JwtTokenFactory {
         if (!scopes.get(0).equals(Authority.REFRESH_TOKEN.name())) {
             throw new IllegalArgumentException("Invalid Refresh Token scope");
         }
+        boolean isPublic = claims.get(IS_PUBLIC, Boolean.class);
+        UserPrincipal principal = new UserPrincipal(isPublic ? UserPrincipal.Type.PUBLIC_ID : UserPrincipal.Type.USER_NAME, subject);
         SecurityUser securityUser = new SecurityUser(new UserId(UUID.fromString(claims.get(USER_ID, String.class))));
-        securityUser.setEmail(subject);
+        securityUser.setUserPrincipal(principal);
         return securityUser;
     }
 
diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/UserPrincipal.java b/application/src/main/java/org/thingsboard/server/service/security/model/UserPrincipal.java
new file mode 100644
index 0000000..6f23783
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/security/model/UserPrincipal.java
@@ -0,0 +1,42 @@
+/**
+ * Copyright © 2016-2017 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.thingsboard.server.service.security.model;
+
+public class UserPrincipal {
+
+    private final Type type;
+    private final String value;
+
+    public UserPrincipal(Type type, String value) {
+        this.type = type;
+        this.value = value;
+    }
+
+    public Type getType() {
+        return type;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+    public enum Type {
+        USER_NAME,
+        PUBLIC_ID
+    }
+
+}
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index 778406a..e64e9cd 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -188,3 +188,25 @@ cache:
 updates:
   # Enable/disable updates checking.
   enabled: "${UPDATES_ENABLED:true}"
+  
+  # spring CORS configuration
+spring.mvc.cors:
+   mappings:
+     # Intercept path
+      "/api/auth/**":
+         #Comma-separated list of origins to allow. '*' allows all origins. When not set,CORS support is disabled.
+         allowed-origins: "*"
+         #Comma-separated list of methods to allow. '*' allows all methods.
+         allowed-methods: "POST,GET,OPTIONS"
+         #Comma-separated list of headers to allow in a request. '*' allows all headers.
+         allowed-headers: "*"
+         #How long, in seconds, the response from a pre-flight request can be cached by clients.
+         max-age: "1800"
+         #Set whether credentials are supported. When not set, credentials are not supported.
+         allow-credentials: "true"
+      "/api/v1/**":
+         allowed-origins: "*"
+         allowed-methods: "*"
+         allowed-headers: "*"
+         max-age: "1800"
+         allow-credentials: "true"
diff --git a/application/src/main/scripts/windows/install.bat b/application/src/main/scripts/windows/install.bat
index 5626614..d124e9a 100644
--- a/application/src/main/scripts/windows/install.bat
+++ b/application/src/main/scripts/windows/install.bat
@@ -2,9 +2,6 @@
 
 setlocal ENABLEEXTENSIONS
 
-IF %PROCESSOR_ARCHITECTURE%==AMD64 GOTO CHECK_JAVA_64
-IF %PROCESSOR_ARCHITECTURE%==x86 GOTO CHECK_JAVA_32
-
 @ECHO Detecting Java version installed.
 :CHECK_JAVA_64
 @ECHO Detecting if it is 64 bit machine
@@ -36,7 +33,6 @@ if defined ValueName (
 )
 
 IF NOT "%JRE_PATH2%" == "" GOTO JAVA_INSTALLED
-IF "%JRE_PATH2%" == "" GOTO JAVA_NOT_INSTALLED
 
 :CHECK_JAVA_32
 @ECHO Detecting if it is 32 bit machine
diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
index 896c549..b1076e0 100644
--- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
@@ -288,7 +288,7 @@ public class CustomerControllerTest extends AbstractControllerTest {
         for (int i=0;i<143;i++) {
             Customer customer = new Customer();
             customer.setTenantId(tenantId);
-            String suffix = RandomStringUtils.randomAlphanumeric((int)(Math.random()*15));
+            String suffix = RandomStringUtils.randomAlphanumeric((int)(5 + Math.random()*10));
             String title = title1+suffix;
             title = i % 2 == 0 ? title.toLowerCase() : title.toUpperCase();
             customer.setTitle(title);
@@ -299,7 +299,7 @@ public class CustomerControllerTest extends AbstractControllerTest {
         for (int i=0;i<175;i++) {
             Customer customer = new Customer();
             customer.setTenantId(tenantId);
-            String suffix = RandomStringUtils.randomAlphanumeric((int)(Math.random()*15));
+            String suffix = RandomStringUtils.randomAlphanumeric((int)(5 + Math.random()*10));
             String title = title2+suffix;
             title = i % 2 == 0 ? title.toLowerCase() : title.toUpperCase();
             customer.setTitle(title);
diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java
index ca4abfe..5d72076 100644
--- a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java
@@ -149,7 +149,7 @@ public class TenantControllerTest extends AbstractControllerTest {
         List<Tenant> tenantsTitle1 = new ArrayList<>();
         for (int i=0;i<134;i++) {
             Tenant tenant = new Tenant();
-            String suffix = RandomStringUtils.randomAlphanumeric((int)(Math.random()*15));
+            String suffix = RandomStringUtils.randomAlphanumeric((int)(5 + Math.random()*10));
             String title = title1+suffix;
             title = i % 2 == 0 ? title.toLowerCase() : title.toUpperCase();
             tenant.setTitle(title);
@@ -159,7 +159,7 @@ public class TenantControllerTest extends AbstractControllerTest {
         List<Tenant> tenantsTitle2 = new ArrayList<>();
         for (int i=0;i<127;i++) {
             Tenant tenant = new Tenant();
-            String suffix = RandomStringUtils.randomAlphanumeric((int)(Math.random()*15));
+            String suffix = RandomStringUtils.randomAlphanumeric((int)(5 + Math.random()*10));
             String title = title2+suffix;
             title = i % 2 == 0 ? title.toLowerCase() : title.toUpperCase();
             tenant.setTitle(title);
diff --git a/common/data/pom.xml b/common/data/pom.xml
index 537a306..aca4c9c 100644
--- a/common/data/pom.xml
+++ b/common/data/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>common</artifactId>
     </parent>
     <groupId>org.thingsboard.common</groupId>
diff --git a/common/message/pom.xml b/common/message/pom.xml
index 3f3f0a9..01d3b45 100644
--- a/common/message/pom.xml
+++ b/common/message/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>common</artifactId>
     </parent>
     <groupId>org.thingsboard.common</groupId>

common/pom.xml 2(+1 -1)

diff --git a/common/pom.xml b/common/pom.xml
index 246ee7b..9c7b51e 100644
--- a/common/pom.xml
+++ b/common/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>thingsboard</artifactId>
     </parent>
     <groupId>org.thingsboard</groupId>
diff --git a/common/transport/pom.xml b/common/transport/pom.xml
index d0eb1a5..4a2bb52 100644
--- a/common/transport/pom.xml
+++ b/common/transport/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>common</artifactId>
     </parent>
     <groupId>org.thingsboard.common</groupId>

dao/pom.xml 2(+1 -1)

diff --git a/dao/pom.xml b/dao/pom.xml
index 2e8afb5..146b8c1 100644
--- a/dao/pom.xml
+++ b/dao/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>thingsboard</artifactId>
     </parent>
     <groupId>org.thingsboard</groupId>
diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDao.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDao.java
index 6d7247b..5639044 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDao.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDao.java
@@ -16,12 +16,14 @@
 package org.thingsboard.server.dao.customer;
 
 import java.util.List;
+import java.util.Optional;
 import java.util.UUID;
 
 import org.thingsboard.server.common.data.Customer;
 import org.thingsboard.server.common.data.page.TextPageLink;
 import org.thingsboard.server.dao.Dao;
 import org.thingsboard.server.dao.model.CustomerEntity;
+import org.thingsboard.server.dao.model.DeviceEntity;
 
 /**
  * The Interface CustomerDao.
@@ -44,5 +46,14 @@ public interface CustomerDao extends Dao<CustomerEntity> {
      * @return the list of customer objects
      */
     List<CustomerEntity> findCustomersByTenantId(UUID tenantId, TextPageLink pageLink);
+
+    /**
+     * Find customers by tenantId and customer title.
+     *
+     * @param tenantId the tenantId
+     * @param title the customer title
+     * @return the optional customer object
+     */
+    Optional<CustomerEntity> findCustomersByTenantIdAndTitle(UUID tenantId, String title);
     
 }
diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDaoImpl.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDaoImpl.java
index 7b53836..25c1116 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDaoImpl.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerDaoImpl.java
@@ -16,11 +16,18 @@
 package org.thingsboard.server.dao.customer;
 
 import static com.datastax.driver.core.querybuilder.QueryBuilder.eq;
+import static com.datastax.driver.core.querybuilder.QueryBuilder.select;
+import static org.thingsboard.server.dao.model.ModelConstants.CUSTOMER_BY_TENANT_AND_TITLE_VIEW_NAME;
+import static org.thingsboard.server.dao.model.ModelConstants.CUSTOMER_TITLE_PROPERTY;
+import static org.thingsboard.server.dao.model.ModelConstants.CUSTOMER_TENANT_ID_PROPERTY;
+
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.Optional;
 import java.util.UUID;
 
+import com.datastax.driver.core.querybuilder.Select;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 import org.thingsboard.server.common.data.Customer;
@@ -60,4 +67,13 @@ public class CustomerDaoImpl extends AbstractSearchTextDao<CustomerEntity> imple
         return customerEntities;
     }
 
+    @Override
+    public Optional<CustomerEntity> findCustomersByTenantIdAndTitle(UUID tenantId, String title) {
+        Select select = select().from(CUSTOMER_BY_TENANT_AND_TITLE_VIEW_NAME);
+        Select.Where query = select.where();
+        query.and(eq(CUSTOMER_TENANT_ID_PROPERTY, tenantId));
+        query.and(eq(CUSTOMER_TITLE_PROPERTY, title));
+        return Optional.ofNullable(findOneByStatement(query));
+    }
+
 }
diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java
index 70321a2..01f4f99 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerService.java
@@ -28,6 +28,8 @@ public interface CustomerService {
     public Customer saveCustomer(Customer customer);
     
     public void deleteCustomer(CustomerId customerId);
+
+    public Customer findOrCreatePublicCustomer(TenantId tenantId);
     
     public TextPageData<Customer> findCustomersByTenantId(TenantId tenantId, TextPageLink pageLink);
     
diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java
index 18ead42..a80c6e8 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java
@@ -18,8 +18,12 @@ package org.thingsboard.server.dao.customer;
 import static org.thingsboard.server.dao.DaoUtil.convertDataList;
 import static org.thingsboard.server.dao.DaoUtil.getData;
 
+import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
 
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.StringUtils;
 import org.thingsboard.server.common.data.Customer;
@@ -46,6 +50,8 @@ import org.thingsboard.server.dao.service.Validator;
 @Slf4j
 public class CustomerServiceImpl implements CustomerService {
 
+    private static final String PUBLIC_CUSTOMER_TITLE = "Public";
+
     @Autowired
     private CustomerDao customerDao;
     
@@ -80,7 +86,7 @@ public class CustomerServiceImpl implements CustomerService {
     @Override
     public void deleteCustomer(CustomerId customerId) {
         log.trace("Executing deleteCustomer [{}]", customerId);
-        Validator.validateId(customerId, "Incorrect tenantId " + customerId);
+        Validator.validateId(customerId, "Incorrect customerId " + customerId);
         Customer customer = findCustomerById(customerId);
         if (customer == null) {
             throw new IncorrectParameterException("Unable to delete non-existent customer.");
@@ -92,6 +98,27 @@ public class CustomerServiceImpl implements CustomerService {
     }
 
     @Override
+    public Customer findOrCreatePublicCustomer(TenantId tenantId) {
+        log.trace("Executing findOrCreatePublicCustomer, tenantId [{}]", tenantId);
+        Validator.validateId(tenantId, "Incorrect customerId " + tenantId);
+        Optional<CustomerEntity> publicCustomerEntity = customerDao.findCustomersByTenantIdAndTitle(tenantId.getId(), PUBLIC_CUSTOMER_TITLE);
+        if (publicCustomerEntity.isPresent()) {
+            return getData(publicCustomerEntity.get());
+        } else {
+            Customer publicCustomer = new Customer();
+            publicCustomer.setTenantId(tenantId);
+            publicCustomer.setTitle(PUBLIC_CUSTOMER_TITLE);
+            try {
+                publicCustomer.setAdditionalInfo(new ObjectMapper().readValue("{ \"isPublic\": true }", JsonNode.class));
+            } catch (IOException e) {
+                throw new IncorrectParameterException("Unable to create public customer.", e);
+            }
+            CustomerEntity customerEntity = customerDao.save(publicCustomer);
+            return getData(customerEntity);
+        }
+    }
+
+    @Override
     public TextPageData<Customer> findCustomersByTenantId(TenantId tenantId, TextPageLink pageLink) {
         log.trace("Executing findCustomersByTenantId, tenantId [{}], pageLink [{}]", tenantId, pageLink);
         Validator.validateId(tenantId, "Incorrect tenantId " + tenantId);
@@ -110,11 +137,35 @@ public class CustomerServiceImpl implements CustomerService {
     
     private DataValidator<Customer> customerValidator =
             new DataValidator<Customer>() {
+
+                @Override
+                protected void validateCreate(Customer customer) {
+                    customerDao.findCustomersByTenantIdAndTitle(customer.getTenantId().getId(), customer.getTitle()).ifPresent(
+                            c -> {
+                                throw new DataValidationException("Customer with such title already exists!");
+                            }
+                    );
+                }
+
+                @Override
+                protected void validateUpdate(Customer customer) {
+                    customerDao.findCustomersByTenantIdAndTitle(customer.getTenantId().getId(), customer.getTitle()).ifPresent(
+                            c -> {
+                                if (!c.getId().equals(customer.getUuidId())) {
+                                    throw new DataValidationException("Customer with such title already exists!");
+                                }
+                            }
+                    );
+                }
+
                 @Override
                 protected void validateDataImpl(Customer customer) {
                     if (StringUtils.isEmpty(customer.getTitle())) {
                         throw new DataValidationException("Customer title should be specified!");
                     }
+                    if (customer.getTitle().equals(PUBLIC_CUSTOMER_TITLE)) {
+                        throw new DataValidationException("'Public' title for customer is system reserved!");
+                    }
                     if (!StringUtils.isEmpty(customer.getEmail())) {
                         validateEmail(customer.getEmail());
                     }
diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
index d3ed5d1..9c9c66b 100644
--- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
+++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java
@@ -111,6 +111,7 @@ public class ModelConstants {
     public static final String CUSTOMER_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY;
 
     public static final String CUSTOMER_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "customer_by_tenant_and_search_text";
+    public static final String CUSTOMER_BY_TENANT_AND_TITLE_VIEW_NAME = "customer_by_tenant_and_title";
 
     /**
      * Cassandra device constants.
diff --git a/dao/src/main/resources/schema.cql b/dao/src/main/resources/schema.cql
index febd1e9..6e45430 100644
--- a/dao/src/main/resources/schema.cql
+++ b/dao/src/main/resources/schema.cql
@@ -137,6 +137,13 @@ CREATE TABLE IF NOT EXISTS thingsboard.customer (
 	PRIMARY KEY (id, tenant_id)
 );
 
+CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.customer_by_tenant_and_title AS
+	SELECT *
+	from thingsboard.customer
+	WHERE tenant_id IS NOT NULL AND title IS NOT NULL AND id IS NOT NULL
+	PRIMARY KEY ( tenant_id, title, id )
+	WITH CLUSTERING ORDER BY ( title ASC, id DESC );
+
 CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.customer_by_tenant_and_search_text AS
 	SELECT *
 	from thingsboard.customer
diff --git a/dao/src/main/resources/system-data.cql b/dao/src/main/resources/system-data.cql
index cd00b1d..fc7c315 100644
--- a/dao/src/main/resources/system-data.cql
+++ b/dao/src/main/resources/system-data.cql
@@ -80,7 +80,7 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', 'gpio_panel',
 
 INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" )
 VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'timeseries_table',
-'{"type":"timeseries","sizeX":8,"sizeY":6.5,"resources":[],"templateHtml":"<md-tabs md-selected=\"sourceIndex\" ng-class=\"{''tb-headless'': sources.length === 1}\"\n    id=\"tabs\" md-border-bottom flex class=\"tb-absolute-fill\">\n    <md-tab ng-repeat=\"source in sources\" label=\"{{ source.datasource.name }}\">\n        <md-table-container>\n            <table md-table>\n                <thead md-head md-order=\"source.query.order\" md-on-reorder=\"onReorder(source)\">\n                    <tr md-row>\n                        <th ng-show=\"showTimestamp\" md-column md-order-by=\"0\"><span>Timestamp</span></th>\n                        <th md-column md-order-by=\"{{ h.index }}\" ng-repeat=\"h in source.ts.header\"><span>{{ h.dataKey.label }}</span></th>\n                    </tr>\n                </thead>\n                <tbody md-body>\n                    <tr md-row ng-repeat=\"row in source.ts.data\">\n                        <td ng-show=\"$index > 0 || ($index === 0 && showTimestamp)\" md-cell ng-repeat=\"d in row track by $index\" ng-style=\"cellStyle(source, $index, d)\" ng-bind-html=\"cellContent(source, $index, row, d)\">\n                        </td>\n                    </tr>    \n                </tbody>    \n            </table>\n        </md-table-container>\n        <md-table-pagination md-limit=\"source.query.limit\" md-limit-options=\"[5, 10, 15]\"\n                             md-page=\"source.query.page\" md-total=\"{{source.ts.count}}\"\n                             md-on-paginate=\"onPaginate(source)\" md-page-select>\n        </md-table-pagination>\n    </md-tab>\n</md-tabs>","templateCss":"table.md-table thead.md-head>tr.md-row {\n    height: 40px;\n}\n\ntable.md-table tbody.md-body>tr.md-row, table.md-table tfoot.md-foot>tr.md-row {\n    height: 38px;\n}\n\n.md-table-pagination>* {\n    height: 46px;\n}\n","controllerScript":"self.onInit = function() {\n    \n    var scope = self.ctx.$scope;\n    \n    self.ctx.filter = scope.$injector.get(\"$filter\");\n\n    scope.sources = [];\n    scope.sourceIndex = 0;\n    scope.showTimestamp = self.ctx.settings.showTimestamp !== false;\n    var origColor = self.ctx.widgetConfig.color || ''rgba(0, 0, 0, 0.87)'';\n    var defaultColor = tinycolor(origColor);\n    var mdDark = defaultColor.setAlpha(0.87).toRgbString();\n    var mdDarkSecondary = defaultColor.setAlpha(0.54).toRgbString();\n    var mdDarkDisabled = defaultColor.setAlpha(0.26).toRgbString();\n    var mdDarkIcon = mdDarkSecondary;\n    var mdDarkDivider = defaultColor.setAlpha(0.12).toRgbString();\n    \n    var cssString = ''table.md-table th.md-column {\\n''+\n    ''color: '' + mdDarkSecondary + '';\\n''+\n    ''}\\n''+\n    ''table.md-table th.md-column md-icon.md-sort-icon {\\n''+\n    ''color: '' + mdDarkDisabled + '';\\n''+\n    ''}\\n''+\n    ''table.md-table th.md-column.md-active, table.md-table th.md-column.md-active md-icon {\\n''+\n    ''color: '' + mdDark + '';\\n''+\n    ''}\\n''+\n    ''table.md-table td.md-cell {\\n''+\n    ''color: '' + mdDark + '';\\n''+\n    ''border-top: 1px ''+mdDarkDivider+'' solid;\\n''+\n    ''}\\n''+\n    ''table.md-table td.md-cell.md-placeholder {\\n''+\n    ''color: '' + mdDarkDisabled + '';\\n''+\n    ''}\\n''+\n    ''table.md-table td.md-cell md-select > .md-select-value > span.md-select-icon {\\n''+\n    ''color: '' + mdDarkSecondary + '';\\n''+\n    ''}\\n''+\n    ''.md-table-pagination {\\n''+\n    ''color: '' + mdDarkSecondary + '';\\n''+\n    ''border-top: 1px ''+mdDarkDivider+'' solid;\\n''+\n    ''}\\n''+\n    ''.md-table-pagination .buttons md-icon {\\n''+\n    ''color: '' + mdDarkSecondary + '';\\n''+\n    ''}\\n''+\n    ''.md-table-pagination md-select:not([disabled]):focus .md-select-value {\\n''+\n    ''color: '' + mdDarkSecondary + '';\\n''+\n    ''}'';\n    \n    var cssParser = new cssjs();\n    cssParser.testMode = false;\n    var namespace = ''ts-table-'' + hashCode(cssString);\n    cssParser.cssPreviewNamespace = namespace;\n    cssParser.createStyleElement(namespace, cssString);\n    self.ctx.$container.addClass(namespace);\n    \n    function hashCode(str) {\n        var hash = 0;\n        var i, char;\n        if (str.length === 0) return hash;\n        for (i = 0; i < str.length; i++) {\n            char = str.charCodeAt(i);\n            hash = ((hash << 5) - hash) + char;\n            hash = hash & hash;\n        }\n        return hash;\n    }\n    \n    var keyOffset = 0;\n    for (var ds = 0; ds < self.ctx.datasources.length; ds++) {\n        var source = {};\n        var datasource = self.ctx.datasources[ds];\n        source.keyStartIndex = keyOffset;\n        keyOffset += datasource.dataKeys.length;\n        source.keyEndIndex = keyOffset;\n        source.datasource = datasource;\n        source.data = [];\n        source.rawData = [];\n        source.query = {\n            limit: 5,\n            page: 1,\n            order: ''-0''\n        }\n        source.ts = {\n            header: [],\n            count: 0,\n            data: [],\n            stylesInfo: [],\n            contentsInfo: [],\n            rowDataTemplate: {}\n        }\n        source.ts.rowDataTemplate[''Timestamp''] = null;\n        for (var a = 0; a < datasource.dataKeys.length; a++ ) {\n            var dataKey = datasource.dataKeys[a];\n            var keySettings = dataKey.settings;\n            source.ts.header.push({\n                index: a+1,\n                dataKey: dataKey\n            });\n            source.ts.rowDataTemplate[dataKey.label] = null;\n\n            var cellStyleFunction = null;\n            var useCellStyleFunction = false;\n            \n            if (keySettings.useCellStyleFunction === true) {\n                if (angular.isDefined(keySettings.cellStyleFunction) && keySettings.cellStyleFunction.length > 0) {\n                    try {\n                       cellStyleFunction = new Function(''value'', keySettings.cellStyleFunction);\n                       useCellStyleFunction = true;\n                    } catch (e) {\n                       cellStyleFunction = null;\n                       useCellStyleFunction = false;\n                    }\n                }\n            }\n\n            source.ts.stylesInfo.push({\n                useCellStyleFunction: useCellStyleFunction,\n                cellStyleFunction: cellStyleFunction\n            });\n            \n            var cellContentFunction = null;\n            var useCellContentFunction = false;\n            \n            if (keySettings.useCellContentFunction === true) {\n                if (angular.isDefined(keySettings.cellContentFunction) && keySettings.cellContentFunction.length > 0) {\n                    try {\n                       cellContentFunction = new Function(''value, rowData, filter'', keySettings.cellContentFunction);\n                       useCellContentFunction = true;\n                    } catch (e) {\n                       cellContentFunction = null;\n                       useCellContentFunction = false;\n                    }\n                }\n            }\n            \n            source.ts.contentsInfo.push({\n                useCellContentFunction: useCellContentFunction,\n                cellContentFunction: cellContentFunction\n            });\n            \n        }\n        scope.sources.push(source);\n    }\n\n    scope.onPaginate = function(source) {\n        updatePage(source);\n    }\n    \n    scope.onReorder = function(source) {\n        reorder(source);\n        updatePage(source);\n    }\n    \n    scope.cellStyle = function(source, index, value) {\n        var style = {};\n        if (index > 0) {\n            var styleInfo = source.ts.stylesInfo[index-1];\n            if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) {\n                try {\n                    style = styleInfo.cellStyleFunction(value);\n                } catch (e) {\n                    style = {};\n                }\n            }\n        }\n        return style;\n    }\n\n    scope.cellContent = function(source, index, row, value) {\n        if (index === 0) {\n            return self.ctx.filter(''date'')(value, ''yyyy-MM-dd HH:mm:ss'');\n        } else {\n            var strContent = '''';\n            if (angular.isDefined(value)) {\n                strContent = ''''+value;\n            }\n            var content = strContent;\n            var contentInfo = source.ts.contentsInfo[index-1];\n            if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) {\n                try {\n                    var rowData = source.ts.rowDataTemplate;\n                    rowData[''Timestamp''] = row[0];\n                    for (var h=0; h < source.ts.header.length; h++) {\n                        var headerInfo = source.ts.header[h];\n                        rowData[headerInfo.dataKey.name] = row[headerInfo.index];\n                    }\n                    content = contentInfo.cellContentFunction(value, rowData, self.ctx.filter);\n                } catch (e) {\n                    content = strContent;\n                }\n            }            \n            return content;\n        }\n    }\n    \n    scope.$watch(''sourceIndex'', function(newIndex, oldIndex) {\n       if (newIndex != oldIndex) {\n           updateSourceData(scope.sources[scope.sourceIndex]);\n       } \n    });\n}\n\nself.onDataUpdated = function() {\n    var scope = self.ctx.$scope;\n    for (var s=0; s < scope.sources.length; s++) {\n        var source = scope.sources[s];\n        source.rawData = self.ctx.data.slice(source.keyStartIndex, source.keyEndIndex);\n    }\n    updateSourceData(scope.sources[scope.sourceIndex]);\n    scope.$digest();\n}\n\nself.onDestroy = function() {\n}\n\nfunction updatePage(source) {\n    var startIndex = source.query.limit * (source.query.page - 1);\n    source.ts.data = source.data.slice(startIndex, startIndex + source.query.limit);\n}\n\nfunction reorder(source) {\n    source.data = self.ctx.filter(''orderBy'')(source.data, source.query.order);\n}\n\nfunction convertData(data) {\n    var rowsMap = {};\n    for (var d = 0; d < data.length; d++) {\n        var columnData = data[d].data;\n        for (var i = 0; i < columnData.length; i++) {\n            var cellData = columnData[i];\n            var timestamp = cellData[0];\n            var row = rowsMap[timestamp];\n            if (!row) {\n                row = [];\n                row[0] = timestamp;\n                for (var c = 0; c < data.length; c++) {\n                    row[c+1] = undefined;\n                }\n                rowsMap[timestamp] = row;\n            }\n            row[d+1] = cellData[1];\n        }\n    }\n    var rows = [];\n    for (var t in rowsMap) {\n        rows.push(rowsMap[t]);\n    }\n    return rows;\n}\n\nfunction updateSourceData(source) {\n    source.data = convertData(source.rawData);\n    source.ts.count = source.data.length;\n    reorder(source);\n    updatePage(source);\n}\n","settingsSchema":"{\n    \"schema\": {\n        \"type\": \"object\",\n        \"title\": \"TimeseriesTableSettings\",\n        \"properties\": {\n            \"showTimestamp\": {\n                \"title\": \"Display timestamp column\",\n                \"type\": \"boolean\",\n                \"default\": true\n            }\n        },\n        \"required\": []\n    },\n    \"form\": [\n        \"showTimestamp\"\n    ]\n}","dataKeySettingsSchema":"{\n    \"schema\": {\n        \"type\": \"object\",\n        \"title\": \"DataKeySettings\",\n        \"properties\": {\n            \"useCellStyleFunction\": {\n                \"title\": \"Use cell style function\",\n                \"type\": \"boolean\",\n                \"default\": false\n            },\n            \"cellStyleFunction\": {\n                \"title\": \"Cell style function: f(value)\",\n                \"type\": \"string\",\n                \"default\": \"\"\n            },\n            \"useCellContentFunction\": {\n                \"title\": \"Use cell content function\",\n                \"type\": \"boolean\",\n                \"default\": false\n            },\n            \"cellContentFunction\": {\n                \"title\": \"Cell content function: f(value, rowData, filter)\",\n                \"type\": \"string\",\n                \"default\": \"\"\n            }\n        },\n        \"required\": []\n    },\n    \"form\": [\n        \"useCellStyleFunction\",\n        {\n            \"key\": \"cellStyleFunction\",\n            \"type\": \"javascript\"\n        },\n        \"useCellContentFunction\",\n        {\n            \"key\": \"cellContentFunction\",\n            \"type\": \"javascript\"\n        }\n    ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature  °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n    var percent = (value + 60)/120 * 100;\\n    var color = tinycolor.mix(''blue'', ''red'', amount = percent);\\n    color.setAlpha(.5);\\n    return {\\n      paddingLeft: ''20px'',\\n      color: ''#ffffff'',\\n      background: color.toRgbString(),\\n      fontSize: ''18px''\\n    };\\n} else {\\n    return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n    var percent = value;\\n    var backgroundColor = tinycolor(''blue'');\\n    backgroundColor.setAlpha(value/100);\\n    var color = ''blue'';\\n    if (value > 50) {\\n        color = ''white'';\\n    }\\n    \\n    return {\\n      paddingLeft: ''20px'',\\n      color: color,\\n      background: backgroundColor.toRgbString(),\\n      fontSize: ''18px''\\n    };\\n} else {\\n    return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":false,\"showLegend\":false}"}',
+'{"type":"timeseries","sizeX":8,"sizeY":6.5,"resources":[],"templateHtml":"<tb-timeseries-table-widget \n    config=\"config\"\n    table-id=\"tableId\"\n    datasources=\"datasources\"\n    data=\"data\">\n</tb-timeseries-table-widget>","templateCss":"","controllerScript":"self.onInit = function() {\n    \n    var scope = self.ctx.$scope;\n    var id = self.ctx.$scope.$injector.get(''utils'').guid();\n\n    scope.config = {\n        settings: self.ctx.settings,\n        widgetConfig: self.ctx.widgetConfig\n    }\n\n    scope.datasources = self.ctx.datasources;\n    scope.data = self.ctx.data;\n    scope.tableId = \"table-\"+id;\n    \n}\n\nself.onDataUpdated = function() {\n    self.ctx.$scope.data = self.ctx.data;\n    self.ctx.$scope.$broadcast(''timeseries-table-data-updated'', self.ctx.$scope.tableId);\n}\n\nself.onDestroy = function() {\n}","settingsSchema":"{\n    \"schema\": {\n        \"type\": \"object\",\n        \"title\": \"TimeseriesTableSettings\",\n        \"properties\": {\n            \"showTimestamp\": {\n                \"title\": \"Display timestamp column\",\n                \"type\": \"boolean\",\n                \"default\": true\n            }\n        },\n        \"required\": []\n    },\n    \"form\": [\n        \"showTimestamp\"\n    ]\n}","dataKeySettingsSchema":"{\n    \"schema\": {\n        \"type\": \"object\",\n        \"title\": \"DataKeySettings\",\n        \"properties\": {\n            \"useCellStyleFunction\": {\n                \"title\": \"Use cell style function\",\n                \"type\": \"boolean\",\n                \"default\": false\n            },\n            \"cellStyleFunction\": {\n                \"title\": \"Cell style function: f(value)\",\n                \"type\": \"string\",\n                \"default\": \"\"\n            },\n            \"useCellContentFunction\": {\n                \"title\": \"Use cell content function\",\n                \"type\": \"boolean\",\n                \"default\": false\n            },\n            \"cellContentFunction\": {\n                \"title\": \"Cell content function: f(value, rowData, filter)\",\n                \"type\": \"string\",\n                \"default\": \"\"\n            }\n        },\n        \"required\": []\n    },\n    \"form\": [\n        \"useCellStyleFunction\",\n        {\n            \"key\": \"cellStyleFunction\",\n            \"type\": \"javascript\"\n        },\n        \"useCellContentFunction\",\n        {\n            \"key\": \"cellContentFunction\",\n            \"type\": \"javascript\"\n        }\n    ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature  °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n    var percent = (value + 60)/120 * 100;\\n    var color = tinycolor.mix(''blue'', ''red'', amount = percent);\\n    color.setAlpha(.5);\\n    return {\\n      paddingLeft: ''20px'',\\n      color: ''#ffffff'',\\n      background: color.toRgbString(),\\n      fontSize: ''18px''\\n    };\\n} else {\\n    return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n    var percent = value;\\n    var backgroundColor = tinycolor(''blue'');\\n    backgroundColor.setAlpha(value/100);\\n    var color = ''blue'';\\n    if (value > 50) {\\n        color = ''white'';\\n    }\\n    \\n    return {\\n      paddingLeft: ''20px'',\\n      color: color,\\n      background: backgroundColor.toRgbString(),\\n      fontSize: ''18px''\\n    };\\n} else {\\n    return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":false,\"showLegend\":false}"}',
 'Timeseries table' );
 
 INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" )
diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceImplTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceImplTest.java
index fd0b20f..723be79 100644
--- a/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceImplTest.java
+++ b/dao/src/test/java/org/thingsboard/server/dao/service/CustomerServiceImplTest.java
@@ -34,11 +34,11 @@ import org.junit.Test;
 import com.datastax.driver.core.utils.UUIDs;
 
 public class CustomerServiceImplTest extends AbstractServiceTest {
-    
+
     private IdComparator<Customer> idComparator = new IdComparator<>();
-    
+
     private TenantId tenantId;
-    
+
     @Before
     public void before() {
         Tenant tenant = new Tenant();
@@ -59,23 +59,23 @@ public class CustomerServiceImplTest extends AbstractServiceTest {
         customer.setTenantId(tenantId);
         customer.setTitle("My customer");
         Customer savedCustomer = customerService.saveCustomer(customer);
-        
+
         Assert.assertNotNull(savedCustomer);
         Assert.assertNotNull(savedCustomer.getId());
         Assert.assertTrue(savedCustomer.getCreatedTime() > 0);
         Assert.assertEquals(customer.getTenantId(), savedCustomer.getTenantId());
         Assert.assertEquals(customer.getTitle(), savedCustomer.getTitle());
-        
-        
+
+
         savedCustomer.setTitle("My new customer");
-        
+
         customerService.saveCustomer(savedCustomer);
         Customer foundCustomer = customerService.findCustomerById(savedCustomer.getId());
         Assert.assertEquals(foundCustomer.getTitle(), savedCustomer.getTitle());
-        
+
         customerService.deleteCustomer(savedCustomer.getId());
     }
-    
+
     @Test
     public void testFindCustomerById() {
         Customer customer = new Customer();
@@ -87,21 +87,21 @@ public class CustomerServiceImplTest extends AbstractServiceTest {
         Assert.assertEquals(savedCustomer, foundCustomer);
         customerService.deleteCustomer(savedCustomer.getId());
     }
-    
+
     @Test(expected = DataValidationException.class)
     public void testSaveCustomerWithEmptyTitle() {
         Customer customer = new Customer();
         customer.setTenantId(tenantId);
         customerService.saveCustomer(customer);
     }
-    
+
     @Test(expected = DataValidationException.class)
     public void testSaveCustomerWithEmptyTenant() {
         Customer customer = new Customer();
         customer.setTitle("My customer");
         customerService.saveCustomer(customer);
     }
-    
+
     @Test(expected = DataValidationException.class)
     public void testSaveCustomerWithInvalidTenant() {
         Customer customer = new Customer();
@@ -109,7 +109,7 @@ public class CustomerServiceImplTest extends AbstractServiceTest {
         customer.setTenantId(new TenantId(UUIDs.timeBased()));
         customerService.saveCustomer(customer);
     }
-    
+
     @Test(expected = DataValidationException.class)
     public void testSaveCustomerWithInvalidEmail() {
         Customer customer = new Customer();
@@ -118,7 +118,7 @@ public class CustomerServiceImplTest extends AbstractServiceTest {
         customer.setEmail("invalid@mail");
         customerService.saveCustomer(customer);
     }
-    
+
     @Test
     public void testDeleteCustomer() {
         Customer customer = new Customer();
@@ -129,23 +129,23 @@ public class CustomerServiceImplTest extends AbstractServiceTest {
         Customer foundCustomer = customerService.findCustomerById(savedCustomer.getId());
         Assert.assertNull(foundCustomer);
     }
-    
+
     @Test
     public void testFindCustomersByTenantId() {
         Tenant tenant = new Tenant();
         tenant.setTitle("Test tenant");
         tenant = tenantService.saveTenant(tenant);
-        
+
         TenantId tenantId = tenant.getId();
-        
+
         List<Customer> customers = new ArrayList<>();
-        for (int i=0;i<135;i++) {
+        for (int i = 0; i < 135; i++) {
             Customer customer = new Customer();
             customer.setTenantId(tenantId);
-            customer.setTitle("Customer"+i);
+            customer.setTitle("Customer" + i);
             customers.add(customerService.saveCustomer(customer));
         }
-        
+
         List<Customer> loadedCustomers = new ArrayList<>();
         TextPageLink pageLink = new TextPageLink(23);
         TextPageData<Customer> pageData = null;
@@ -156,47 +156,47 @@ public class CustomerServiceImplTest extends AbstractServiceTest {
                 pageLink = pageData.getNextPageLink();
             }
         } while (pageData.hasNext());
-        
+
         Collections.sort(customers, idComparator);
         Collections.sort(loadedCustomers, idComparator);
-        
+
         Assert.assertEquals(customers, loadedCustomers);
-        
+
         customerService.deleteCustomersByTenantId(tenantId);
 
         pageLink = new TextPageLink(33);
         pageData = customerService.findCustomersByTenantId(tenantId, pageLink);
         Assert.assertFalse(pageData.hasNext());
         Assert.assertTrue(pageData.getData().isEmpty());
-        
+
         tenantService.deleteTenant(tenantId);
     }
-    
+
     @Test
     public void testFindCustomersByTenantIdAndTitle() {
         String title1 = "Customer title 1";
         List<Customer> customersTitle1 = new ArrayList<>();
-        for (int i=0;i<143;i++) {
+        for (int i = 0; i < 143; i++) {
             Customer customer = new Customer();
             customer.setTenantId(tenantId);
-            String suffix = RandomStringUtils.randomAlphanumeric((int)(Math.random()*15));
-            String title = title1+suffix;
+            String suffix = RandomStringUtils.randomAlphanumeric((int)(5 + Math.random()*10));
+            String title = title1 + suffix;
             title = i % 2 == 0 ? title.toLowerCase() : title.toUpperCase();
             customer.setTitle(title);
             customersTitle1.add(customerService.saveCustomer(customer));
         }
         String title2 = "Customer title 2";
         List<Customer> customersTitle2 = new ArrayList<>();
-        for (int i=0;i<175;i++) {
+        for (int i = 0; i < 175; i++) {
             Customer customer = new Customer();
             customer.setTenantId(tenantId);
-            String suffix = RandomStringUtils.randomAlphanumeric((int)(Math.random()*15));
-            String title = title2+suffix;
+            String suffix = RandomStringUtils.randomAlphanumeric((int)(5 + Math.random()*10));
+            String title = title2 + suffix;
             title = i % 2 == 0 ? title.toLowerCase() : title.toUpperCase();
             customer.setTitle(title);
             customersTitle2.add(customerService.saveCustomer(customer));
         }
-        
+
         List<Customer> loadedCustomersTitle1 = new ArrayList<>();
         TextPageLink pageLink = new TextPageLink(15, title1);
         TextPageData<Customer> pageData = null;
@@ -207,12 +207,12 @@ public class CustomerServiceImplTest extends AbstractServiceTest {
                 pageLink = pageData.getNextPageLink();
             }
         } while (pageData.hasNext());
-        
+
         Collections.sort(customersTitle1, idComparator);
         Collections.sort(loadedCustomersTitle1, idComparator);
-        
+
         Assert.assertEquals(customersTitle1, loadedCustomersTitle1);
-        
+
         List<Customer> loadedCustomersTitle2 = new ArrayList<>();
         pageLink = new TextPageLink(4, title2);
         do {
@@ -225,22 +225,22 @@ public class CustomerServiceImplTest extends AbstractServiceTest {
 
         Collections.sort(customersTitle2, idComparator);
         Collections.sort(loadedCustomersTitle2, idComparator);
-        
+
         Assert.assertEquals(customersTitle2, loadedCustomersTitle2);
 
         for (Customer customer : loadedCustomersTitle1) {
             customerService.deleteCustomer(customer.getId());
         }
-        
+
         pageLink = new TextPageLink(4, title1);
         pageData = customerService.findCustomersByTenantId(tenantId, pageLink);
         Assert.assertFalse(pageData.hasNext());
         Assert.assertEquals(0, pageData.getData().size());
-        
+
         for (Customer customer : loadedCustomersTitle2) {
             customerService.deleteCustomer(customer.getId());
         }
-        
+
         pageLink = new TextPageLink(4, title2);
         pageData = customerService.findCustomersByTenantId(tenantId, pageLink);
         Assert.assertFalse(pageData.hasNext());
diff --git a/extensions/extension-kafka/pom.xml b/extensions/extension-kafka/pom.xml
index 7e54bfb..9ec8238 100644
--- a/extensions/extension-kafka/pom.xml
+++ b/extensions/extension-kafka/pom.xml
@@ -22,7 +22,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>extensions</artifactId>
     </parent>
     <groupId>org.thingsboard.extensions</groupId>
diff --git a/extensions/extension-rabbitmq/pom.xml b/extensions/extension-rabbitmq/pom.xml
index 355d22d..93503e2 100644
--- a/extensions/extension-rabbitmq/pom.xml
+++ b/extensions/extension-rabbitmq/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>extensions</artifactId>
     </parent>
     <groupId>org.thingsboard.extensions</groupId>
diff --git a/extensions/extension-rest-api-call/pom.xml b/extensions/extension-rest-api-call/pom.xml
index 40a446e..9646e36 100644
--- a/extensions/extension-rest-api-call/pom.xml
+++ b/extensions/extension-rest-api-call/pom.xml
@@ -22,7 +22,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>extensions</artifactId>
     </parent>
     <groupId>org.thingsboard.extensions</groupId>
diff --git a/extensions/extension-rest-api-call/src/main/java/org/thingsboard/server/extensions/rest/plugin/RestApiCallPlugin.java b/extensions/extension-rest-api-call/src/main/java/org/thingsboard/server/extensions/rest/plugin/RestApiCallPlugin.java
index 797ebf5..a07c80d 100644
--- a/extensions/extension-rest-api-call/src/main/java/org/thingsboard/server/extensions/rest/plugin/RestApiCallPlugin.java
+++ b/extensions/extension-rest-api-call/src/main/java/org/thingsboard/server/extensions/rest/plugin/RestApiCallPlugin.java
@@ -55,6 +55,13 @@ public class RestApiCallPlugin extends AbstractPlugin<RestApiCallPluginConfigura
             this.headers.add(AUTHORIZATION_HEADER_NAME, String.format(AUTHORIZATION_HEADER_FORMAT, new String(token)));
         }
 
+        if (configuration.getHeaders() != null) {
+            configuration.getHeaders().forEach(h -> {
+                log.debug("Adding header to request object. Key = {}, Value = {}", h.getKey(), h.getValue());
+                this.headers.add(h.getKey(), h.getValue());
+            });
+        }
+
         init();
     }
 
diff --git a/extensions/extension-rest-api-call/src/main/java/org/thingsboard/server/extensions/rest/plugin/RestApiCallPluginConfiguration.java b/extensions/extension-rest-api-call/src/main/java/org/thingsboard/server/extensions/rest/plugin/RestApiCallPluginConfiguration.java
index 2b20e9b..cfd23b8 100644
--- a/extensions/extension-rest-api-call/src/main/java/org/thingsboard/server/extensions/rest/plugin/RestApiCallPluginConfiguration.java
+++ b/extensions/extension-rest-api-call/src/main/java/org/thingsboard/server/extensions/rest/plugin/RestApiCallPluginConfiguration.java
@@ -16,6 +16,9 @@
 package org.thingsboard.server.extensions.rest.plugin;
 
 import lombok.Data;
+import org.thingsboard.server.extensions.core.plugin.KeyValuePluginProperties;
+
+import java.util.List;
 
 @Data
 public class RestApiCallPluginConfiguration {
@@ -27,4 +30,6 @@ public class RestApiCallPluginConfiguration {
 
     private String userName;
     private String password;
+
+    private List<KeyValuePluginProperties> headers;
 }
diff --git a/extensions/extension-rest-api-call/src/main/resources/RestApiCallPluginDescriptor.json b/extensions/extension-rest-api-call/src/main/resources/RestApiCallPluginDescriptor.json
index e0e4d18..06f8559 100644
--- a/extensions/extension-rest-api-call/src/main/resources/RestApiCallPluginDescriptor.json
+++ b/extensions/extension-rest-api-call/src/main/resources/RestApiCallPluginDescriptor.json
@@ -30,6 +30,24 @@
       "password": {
         "title": "Password",
         "type": "string"
+      },
+      "headers": {
+        "title": "Request Headers",
+        "type": "array",
+        "items": {
+          "title": "Request Header",
+          "type": "object",
+          "properties": {
+            "key": {
+              "title": "Key",
+              "type": "string"
+            },
+            "value": {
+              "title": "Value",
+              "type": "string"
+            }
+          }
+        }
       }
     },
     "required": [
@@ -62,6 +80,7 @@
     {
       "key": "password",
       "type": "password"
-    }
+    },
+    "headers"
   ]
 }
\ No newline at end of file
diff --git a/extensions/pom.xml b/extensions/pom.xml
index 44830b1..63b34cf 100644
--- a/extensions/pom.xml
+++ b/extensions/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>thingsboard</artifactId>
     </parent>
     <groupId>org.thingsboard</groupId>
diff --git a/extensions-api/pom.xml b/extensions-api/pom.xml
index 71e1953..b99d274 100644
--- a/extensions-api/pom.xml
+++ b/extensions-api/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>thingsboard</artifactId>
     </parent>
     <groupId>org.thingsboard</groupId>
diff --git a/extensions-core/pom.xml b/extensions-core/pom.xml
index 4664b15..9ccc087 100644
--- a/extensions-core/pom.xml
+++ b/extensions-core/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>thingsboard</artifactId>
     </parent>
     <groupId>org.thingsboard</groupId>
diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java
index c7cb47c..633f620 100644
--- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java
+++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/handlers/TelemetryRestMsgHandler.java
@@ -139,7 +139,12 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler {
             public void onSuccess(PluginContext ctx, List<TsKvEntry> data) {
                 Map<String, List<TsData>> result = new LinkedHashMap<>();
                 for (TsKvEntry entry : data) {
-                    result.put(entry.getKey(), data.stream().map(v -> new TsData(v.getTs(), v.getValueAsString())).collect(Collectors.toList()));
+                    List<TsData> vList = result.get(entry.getKey());
+                    if (vList == null) {
+                        vList = new ArrayList<>();
+                        result.put(entry.getKey(), vList);
+                    }
+                    vList.add(new TsData(entry.getTs(), entry.getValueAsString()));
                 }
                 msg.getResponseHolder().setResult(new ResponseEntity<>(result, HttpStatus.OK));
             }
diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/SubscriptionManager.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/SubscriptionManager.java
index ea7a185..8cb1bf3 100644
--- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/SubscriptionManager.java
+++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/telemetry/SubscriptionManager.java
@@ -15,7 +15,6 @@
  */
 package org.thingsboard.server.extensions.core.plugin.telemetry;
 
-import com.sun.javafx.collections.MappingChange;
 import lombok.Setter;
 import lombok.extern.slf4j.Slf4j;
 import org.thingsboard.server.common.data.DataConstants;
@@ -292,7 +291,6 @@ public class SubscriptionManager {
                     } else {
                         log.trace("[{}] Remote subscription is now handled on new server address: [{}]", s.getWsSessionId(), newAddress);
                         subscriptionIterator.remove();
-
                         //TODO: onUpdate state of subscription by WsSessionId and other maps.
                     }
                 }
diff --git a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/time/TimePlugin.java b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/time/TimePlugin.java
index 4e7793e..ac168d0 100644
--- a/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/time/TimePlugin.java
+++ b/extensions-core/src/main/java/org/thingsboard/server/extensions/core/plugin/time/TimePlugin.java
@@ -30,7 +30,7 @@ import org.thingsboard.server.extensions.api.plugins.msg.RuleToPluginMsg;
 import org.thingsboard.server.extensions.api.rules.RuleException;
 import org.thingsboard.server.extensions.core.action.rpc.RpcPluginAction;
 
-import java.time.LocalDateTime;
+import java.time.ZonedDateTime;
 import java.time.format.DateTimeFormatter;
 
 /**
@@ -51,7 +51,7 @@ public class TimePlugin extends AbstractPlugin<TimePluginConfiguration> implemen
 
             String reply;
             if (!StringUtils.isEmpty(format)) {
-                reply = "\"" + formatter.format(LocalDateTime.now()) + "\"";
+                reply = "\"" + formatter.format(ZonedDateTime.now()) + "\"";
             } else {
                 reply = Long.toString(System.currentTimeMillis());
             }

pom.xml 2(+1 -1)

diff --git a/pom.xml b/pom.xml
index b85f692..6324712 100755
--- a/pom.xml
+++ b/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <groupId>org.thingsboard</groupId>
     <artifactId>thingsboard</artifactId>
-    <version>1.2.2</version>
+    <version>1.2.3-SNAPSHOT</version>
     <packaging>pom</packaging>
 
     <name>Thingsboard</name>

README.md 14(+7 -7)

diff --git a/README.md b/README.md
index 4639932..f6a514c 100644
--- a/README.md
+++ b/README.md
@@ -10,16 +10,16 @@ Thingsboard is an open-source IoT platform for data collection, processing, visu
 
 Thingsboard documentation is hosted on [thingsboard.io](https://thingsboard.io/docs).
 
-## Sample Dashboards
+## IoT use cases
 
-[**Smart energy monitoring**](https://demo.thingsboard.io/demo?dashboardId=e8e409c0-f2b5-11e6-a6ee-bb0136cc33d0&source=github)
-[![Smart energy monitoring demo](https://cloud.githubusercontent.com/assets/8308069/23790111/62c8a61e-0586-11e7-84eb-51febc54ec09.png "Smart energy monitoring demo")](https://demo.thingsboard.io/demo?dashboardId=e8e409c0-f2b5-11e6-a6ee-bb0136cc33d0&source=github)
+[**Smart energy**](https://thingsboard.io/smart-energy/)
+[![Smart energy monitoring demo](https://cloud.githubusercontent.com/assets/8308069/24495682/aebd45d0-153e-11e7-8de4-7360ed5b41ae.gif "Smart energy")](https://thingsboard.io/smart-energy/)
 
-[**Silos monitoring**](https://demo.thingsboard.io/demo?dashboardId=1f9828d0-058e-11e7-87f7-bb0136cc33d0&source=github)
-[![Silos monitoring demo](https://cloud.githubusercontent.com/assets/8308069/23996135/00214844-0a55-11e7-9623-d1e3be0702ca.png "Silos monitoring demo")](https://demo.thingsboard.io/demo?dashboardId=1f9828d0-058e-11e7-87f7-bb0136cc33d0&source=github)
+[**Smart farming**](https://thingsboard.io/smart-farming/)
+[![Smart farming](https://cloud.githubusercontent.com/assets/8308069/24496824/10dc1144-1542-11e7-8aa1-5d3a281d5a1a.gif "Smart farming")](https://thingsboard.io/smart-farming/)
 
-[**Smart bus tracking**](https://demo.thingsboard.io/demo?dashboardId=3d0bf910-ee09-11e6-b619-bb0136cc33d0&source=github)
-[![Smart bus tracking demo](https://cloud.githubusercontent.com/assets/8308069/23790110/62c6ecde-0586-11e7-8249-19eafd5bf8cc.png "Smart bus tracking demo")](https://demo.thingsboard.io/demo?dashboardId=3d0bf910-ee09-11e6-b619-bb0136cc33d0&source=github)
+[**Fleet tracking**](https://thingsboard.io/fleet-tracking/)
+[![Fleet tracking](https://cloud.githubusercontent.com/assets/8308069/24497169/3a1a61e0-1543-11e7-8d55-3c8a13f35634.gif "Fleet tracking")](https://thingsboard.io/fleet-tracking/)
 
 ## Getting Started
 

tools/pom.xml 38(+1 -37)

diff --git a/tools/pom.xml b/tools/pom.xml
index 94b0066..96235e2 100644
--- a/tools/pom.xml
+++ b/tools/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>thingsboard</artifactId>
     </parent>
     <groupId>org.thingsboard</groupId>
@@ -54,40 +54,4 @@
         </dependency>
     </dependencies>
 
-    <build>
-        <plugins>
-            <plugin>
-                <groupId>org.apache.maven.plugins</groupId>
-                <artifactId>maven-shade-plugin</artifactId>
-                <executions>
-                    <execution>
-                        <phase>package</phase>
-                        <goals>
-                            <goal>shade</goal>
-                        </goals>
-                        <configuration>
-                            <filters>
-                                <filter>
-                                    <artifact>*:*</artifact>
-                                    <excludes>
-                                        <exclude>META-INF/*.SF</exclude>
-                                        <exclude>META-INF/*.DSA</exclude>
-                                        <exclude>META-INF/*.RSA</exclude>
-                                    </excludes>
-                                </filter>
-                            </filters>
-                            <transformers>
-                                <transformer
-                                    implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
-                                    <manifestEntries>
-                                        <Main-Class>org.thingsboard.client.tools.MqttStressTestTool</Main-Class>
-                                    </manifestEntries>
-                                </transformer>
-                            </transformers>
-                        </configuration>
-                    </execution>
-                </executions>
-            </plugin>
-        </plugins>
-    </build>
 </project>
diff --git a/tools/src/main/java/org/thingsboard/client/tools/RestClient.java b/tools/src/main/java/org/thingsboard/client/tools/RestClient.java
index f742e61..d635dbc 100644
--- a/tools/src/main/java/org/thingsboard/client/tools/RestClient.java
+++ b/tools/src/main/java/org/thingsboard/client/tools/RestClient.java
@@ -27,6 +27,7 @@ import org.springframework.http.client.support.HttpRequestWrapper;
 import org.springframework.web.client.HttpClientErrorException;
 import org.springframework.web.client.RestTemplate;
 import org.thingsboard.server.common.data.Device;
+import org.thingsboard.server.common.data.id.CustomerId;
 import org.thingsboard.server.common.data.id.DeviceId;
 import org.thingsboard.server.common.data.security.DeviceCredentials;
 
@@ -76,6 +77,12 @@ public class RestClient implements ClientHttpRequestInterceptor {
         return restTemplate.postForEntity(baseURL + "/api/device", device, Device.class).getBody();
     }
 
+
+    public Device assignDevice(CustomerId customerId, DeviceId deviceId) {
+        return restTemplate.postForEntity(baseURL + "/api/customer/{customerId}/device/{deviceId}", null, Device.class,
+                customerId.toString(), deviceId.toString()).getBody();
+    }
+
     public DeviceCredentials getCredentials(DeviceId id) {
         return restTemplate.getForEntity(baseURL + "/api/device/" + id.getId().toString() + "/credentials", DeviceCredentials.class).getBody();
     }
diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml
index 426337d..ef3953d 100644
--- a/transport/coap/pom.xml
+++ b/transport/coap/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>transport</artifactId>
     </parent>
     <groupId>org.thingsboard.transport</groupId>
diff --git a/transport/http/pom.xml b/transport/http/pom.xml
index 017cbad..16e5626 100644
--- a/transport/http/pom.xml
+++ b/transport/http/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>transport</artifactId>
     </parent>
     <groupId>org.thingsboard.transport</groupId>
diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml
index fe3f734..15c640c 100644
--- a/transport/mqtt/pom.xml
+++ b/transport/mqtt/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>transport</artifactId>
     </parent>
     <groupId>org.thingsboard.transport</groupId>
diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java
index 76cdf1a..36868c5 100644
--- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java
+++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java
@@ -247,7 +247,7 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement
 
     private MqttMessage createUnSubAckMessage(int msgId) {
         MqttFixedHeader mqttFixedHeader =
-                new MqttFixedHeader(SUBACK, false, AT_LEAST_ONCE, false, 0);
+                new MqttFixedHeader(UNSUBACK, false, AT_LEAST_ONCE, false, 0);
         MqttMessageIdVariableHeader mqttMessageIdVariableHeader = MqttMessageIdVariableHeader.from(msgId);
         return new MqttMessage(mqttFixedHeader, mqttMessageIdVariableHeader);
     }
diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java
index 9444cdd..af109f6 100644
--- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java
+++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java
@@ -40,6 +40,8 @@ import java.security.cert.CertificateException;
  */
 public class MqttTransportServerInitializer extends ChannelInitializer<SocketChannel> {
 
+    private static final int MAX_PAYLOAD_SIZE = 64 * 1024 * 1024;
+
     private final SessionMsgProcessor processor;
     private final DeviceService deviceService;
     private final DeviceAuthService authService;
@@ -63,7 +65,7 @@ public class MqttTransportServerInitializer extends ChannelInitializer<SocketCha
             sslHandler = sslHandlerProvider.getSslHandler();
             pipeline.addLast(sslHandler);
         }
-        pipeline.addLast("decoder", new MqttDecoder());
+        pipeline.addLast("decoder", new MqttDecoder(MAX_PAYLOAD_SIZE));
         pipeline.addLast("encoder", MqttEncoder.INSTANCE);
 
         MqttTransportHandler handler = new MqttTransportHandler(processor, deviceService, authService, adaptor, sslHandler);
diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java
index c7deeec..b8de7c5 100644
--- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java
+++ b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java
@@ -134,7 +134,7 @@ public class GatewaySessionCtx {
             JsonObject jsonObj = json.getAsJsonObject();
             String deviceName = checkDeviceConnected(jsonObj.get("device").getAsString());
             Integer requestId = jsonObj.get("id").getAsInt();
-            String data = jsonObj.get("data").getAsString();
+            String data = jsonObj.get("data").toString();
             GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName);
             processor.process(new BasicToDeviceActorSessionMsg(deviceSessionCtx.getDevice(),
                     new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new ToDeviceRpcResponseMsg(requestId, data))));
diff --git a/transport/pom.xml b/transport/pom.xml
index 13e1994..bca6091 100644
--- a/transport/pom.xml
+++ b/transport/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>thingsboard</artifactId>
     </parent>
     <groupId>org.thingsboard</groupId>

ui/package.json 4(+3 -1)

diff --git a/ui/package.json b/ui/package.json
index 37152a5..ddcc96e 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -1,7 +1,7 @@
 {
   "name": "thingsboard",
   "private": true,
-  "version": "1.2.2",
+  "version": "1.2.3",
   "description": "Thingsboard UI",
   "licenses": [
     {
@@ -33,6 +33,7 @@
     "angular-messages": "1.5.8",
     "angular-route": "1.5.8",
     "angular-sanitize": "1.5.8",
+    "angular-socialshare": "^2.3.8",
     "angular-storage": "0.0.15",
     "angular-touch": "1.5.8",
     "angular-translate": "2.13.1",
@@ -49,6 +50,7 @@
     "clipboard": "^1.5.15",
     "compass-sass-mixins": "^0.12.7",
     "flot": "git://github.com/flot/flot.git#0.9-work",
+    "flot-curvedlines": "git://github.com/MichaelZinsmaier/CurvedLines.git#master",
     "font-awesome": "^4.6.3",
     "javascript-detect-element-resize": "^0.5.3",
     "jquery": "^3.1.0",

ui/pom.xml 2(+1 -1)

diff --git a/ui/pom.xml b/ui/pom.xml
index e9e8f10..1ffaa67 100644
--- a/ui/pom.xml
+++ b/ui/pom.xml
@@ -20,7 +20,7 @@
     <modelVersion>4.0.0</modelVersion>
     <parent>
         <groupId>org.thingsboard</groupId>
-        <version>1.2.2</version>
+        <version>1.2.3-SNAPSHOT</version>
         <artifactId>thingsboard</artifactId>
     </parent>
     <groupId>org.thingsboard</groupId>
diff --git a/ui/src/app/api/customer.service.js b/ui/src/app/api/customer.service.js
index 5524e91..b36b44a 100644
--- a/ui/src/app/api/customer.service.js
+++ b/ui/src/app/api/customer.service.js
@@ -18,11 +18,14 @@ export default angular.module('thingsboard.api.customer', [])
     .name;
 
 /*@ngInject*/
-function CustomerService($http, $q) {
+function CustomerService($http, $q, types) {
 
     var service = {
         getCustomers: getCustomers,
         getCustomer: getCustomer,
+        getShortCustomerInfo: getShortCustomerInfo,
+        applyAssignedCustomersInfo: applyAssignedCustomersInfo,
+        applyAssignedCustomerInfo: applyAssignedCustomerInfo,
         deleteCustomer: deleteCustomer,
         saveCustomer: saveCustomer
     }
@@ -60,6 +63,88 @@ function CustomerService($http, $q) {
         return deferred.promise;
     }
 
+    function getShortCustomerInfo(customerId) {
+        var deferred = $q.defer();
+        var url = '/api/customer/' + customerId + '/shortInfo';
+        $http.get(url, null).then(function success(response) {
+            deferred.resolve(response.data);
+        }, function fail(response) {
+            deferred.reject(response.data);
+        });
+        return deferred.promise;
+    }
+
+    function applyAssignedCustomersInfo(items) {
+        var deferred = $q.defer();
+        var assignedCustomersMap = {};
+        function loadNextCustomerInfoOrComplete(i) {
+            i++;
+            if (i < items.length) {
+                loadNextCustomerInfo(i);
+            } else {
+                deferred.resolve(items);
+            }
+        }
+
+        function loadNextCustomerInfo(i) {
+            var item = items[i];
+            item.assignedCustomer = {};
+            if (item.customerId && item.customerId.id != types.id.nullUid) {
+                item.assignedCustomer.id = item.customerId.id;
+                var assignedCustomer = assignedCustomersMap[item.customerId.id];
+                if (assignedCustomer){
+                    item.assignedCustomer = assignedCustomer;
+                    loadNextCustomerInfoOrComplete(i);
+                } else {
+                    getShortCustomerInfo(item.customerId.id).then(
+                        function success(info) {
+                            assignedCustomer = {
+                                id: item.customerId.id,
+                                title: info.title,
+                                isPublic: info.isPublic
+                            };
+                            assignedCustomersMap[assignedCustomer.id] = assignedCustomer;
+                            item.assignedCustomer = assignedCustomer;
+                            loadNextCustomerInfoOrComplete(i);
+                        },
+                        function fail() {
+                            loadNextCustomerInfoOrComplete(i);
+                        }
+                    );
+                }
+            } else {
+                loadNextCustomerInfoOrComplete(i);
+            }
+        }
+        if (items.length > 0) {
+            loadNextCustomerInfo(0);
+        } else {
+            deferred.resolve(items);
+        }
+        return deferred.promise;
+    }
+
+    function applyAssignedCustomerInfo(items, customerId) {
+        var deferred = $q.defer();
+        getShortCustomerInfo(customerId).then(
+            function success(info) {
+                var assignedCustomer = {
+                    id: customerId,
+                    title: info.title,
+                    isPublic: info.isPublic
+                }
+                items.forEach(function(item) {
+                    item.assignedCustomer = assignedCustomer;
+                });
+                deferred.resolve(items);
+            },
+            function fail() {
+                deferred.reject();
+            }
+        );
+        return deferred.promise;
+    }
+
     function saveCustomer(customer) {
         var deferred = $q.defer();
         var url = '/api/customer';
diff --git a/ui/src/app/api/dashboard.service.js b/ui/src/app/api/dashboard.service.js
index be450ed..b2a7897 100644
--- a/ui/src/app/api/dashboard.service.js
+++ b/ui/src/app/api/dashboard.service.js
@@ -17,7 +17,7 @@ export default angular.module('thingsboard.api.dashboard', [])
     .factory('dashboardService', DashboardService).name;
 
 /*@ngInject*/
-function DashboardService($http, $q) {
+function DashboardService($http, $q, $location, customerService) {
 
     var service = {
         assignDashboardToCustomer: assignDashboardToCustomer,
@@ -27,7 +27,9 @@ function DashboardService($http, $q) {
         getTenantDashboards: getTenantDashboards,
         deleteDashboard: deleteDashboard,
         saveDashboard: saveDashboard,
-        unassignDashboardFromCustomer: unassignDashboardFromCustomer
+        unassignDashboardFromCustomer: unassignDashboardFromCustomer,
+        makeDashboardPublic: makeDashboardPublic,
+        getPublicDashboardLink: getPublicDashboardLink
     }
 
     return service;
@@ -45,7 +47,15 @@ function DashboardService($http, $q) {
             url += '&textOffset=' + pageLink.textOffset;
         }
         $http.get(url, null).then(function success(response) {
-            deferred.resolve(response.data);
+            customerService.applyAssignedCustomersInfo(response.data.data).then(
+                function success(data) {
+                    response.data.data = data;
+                    deferred.resolve(response.data);
+                },
+                function fail() {
+                    deferred.reject();
+                }
+            );
         }, function fail() {
             deferred.reject();
         });
@@ -65,7 +75,15 @@ function DashboardService($http, $q) {
             url += '&textOffset=' + pageLink.textOffset;
         }
         $http.get(url, null).then(function success(response) {
-            deferred.resolve(response.data);
+            customerService.applyAssignedCustomerInfo(response.data.data, customerId).then(
+                function success(data) {
+                    response.data.data = data;
+                    deferred.resolve(response.data);
+                },
+                function fail() {
+                    deferred.reject();
+                }
+            );
         }, function fail() {
             deferred.reject();
         });
@@ -92,8 +110,8 @@ function DashboardService($http, $q) {
         var url = '/api/dashboard/' + dashboardId;
         $http.get(url, null).then(function success(response) {
             deferred.resolve(response.data);
-        }, function fail(response) {
-            deferred.reject(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
@@ -103,8 +121,8 @@ function DashboardService($http, $q) {
         var url = '/api/dashboard';
         $http.post(url, dashboard).then(function success(response) {
             deferred.resolve(response.data);
-        }, function fail(response) {
-            deferred.reject(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
@@ -114,8 +132,8 @@ function DashboardService($http, $q) {
         var url = '/api/dashboard/' + dashboardId;
         $http.delete(url).then(function success() {
             deferred.resolve();
-        }, function fail(response) {
-            deferred.reject(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
@@ -123,10 +141,10 @@ function DashboardService($http, $q) {
     function assignDashboardToCustomer(customerId, dashboardId) {
         var deferred = $q.defer();
         var url = '/api/customer/' + customerId + '/dashboard/' + dashboardId;
-        $http.post(url, null).then(function success() {
-            deferred.resolve();
-        }, function fail(response) {
-            deferred.reject(response.data);
+        $http.post(url, null).then(function success(response) {
+            deferred.resolve(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
@@ -134,12 +152,33 @@ function DashboardService($http, $q) {
     function unassignDashboardFromCustomer(dashboardId) {
         var deferred = $q.defer();
         var url = '/api/customer/dashboard/' + dashboardId;
-        $http.delete(url).then(function success() {
-            deferred.resolve();
-        }, function fail(response) {
-            deferred.reject(response.data);
+        $http.delete(url).then(function success(response) {
+            deferred.resolve(response.data);
+        }, function fail() {
+            deferred.reject();
+        });
+        return deferred.promise;
+    }
+
+    function makeDashboardPublic(dashboardId) {
+        var deferred = $q.defer();
+        var url = '/api/customer/public/dashboard/' + dashboardId;
+        $http.post(url, null).then(function success(response) {
+            deferred.resolve(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
 
+    function getPublicDashboardLink(dashboard) {
+        var url = $location.protocol() + '://' + $location.host();
+        var port = $location.port();
+        if (port != 80 && port != 443) {
+            url += ":" + port;
+        }
+        url += "/dashboards/" + dashboard.id.id + "?publicId=" + dashboard.customerId.id;
+        return url;
+    }
+
 }
diff --git a/ui/src/app/api/datasource.service.js b/ui/src/app/api/datasource.service.js
index 6041cbd..f87d547 100644
--- a/ui/src/app/api/datasource.service.js
+++ b/ui/src/app/api/datasource.service.js
@@ -58,10 +58,10 @@ function DatasourceService($timeout, $filter, $log, telemetryWebsocketService, t
         var datasourceSubscription = {
             datasourceType: datasource.type,
             dataKeys: subscriptionDataKeys,
-            type: listener.widget.type
+            type: listener.subscriptionType
         };
 
-        if (listener.widget.type === types.widgetType.timeseries.value) {
+        if (listener.subscriptionType === types.widgetType.timeseries.value) {
             datasourceSubscription.subscriptionTimewindow = angular.copy(listener.subscriptionTimewindow);
         }
         if (datasourceSubscription.datasourceType === types.datasourceType.device) {
diff --git a/ui/src/app/api/device.service.js b/ui/src/app/api/device.service.js
index 2c4a100..ea197a1 100644
--- a/ui/src/app/api/device.service.js
+++ b/ui/src/app/api/device.service.js
@@ -20,7 +20,7 @@ export default angular.module('thingsboard.api.device', [thingsboardTypes])
     .name;
 
 /*@ngInject*/
-function DeviceService($http, $q, $filter, userService, telemetryWebsocketService, types) {
+function DeviceService($http, $q, $filter, userService, customerService, telemetryWebsocketService, types) {
 
 
     var deviceAttributesSubscriptionMap = {};
@@ -33,6 +33,7 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         getDevices: getDevices,
         processDeviceAliases: processDeviceAliases,
         checkDeviceAlias: checkDeviceAlias,
+        fetchAliasDeviceByNameFilter: fetchAliasDeviceByNameFilter,
         getDeviceCredentials: getDeviceCredentials,
         getDeviceKeys: getDeviceKeys,
         getDeviceTimeseriesValues: getDeviceTimeseriesValues,
@@ -40,6 +41,7 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         saveDevice: saveDevice,
         saveDeviceCredentials: saveDeviceCredentials,
         unassignDeviceFromCustomer: unassignDeviceFromCustomer,
+        makeDevicePublic: makeDevicePublic,
         getDeviceAttributes: getDeviceAttributes,
         subscribeForDeviceAttributes: subscribeForDeviceAttributes,
         unsubscribeForDeviceAttributes: unsubscribeForDeviceAttributes,
@@ -51,7 +53,7 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
 
     return service;
 
-    function getTenantDevices(pageLink) {
+    function getTenantDevices(pageLink, applyCustomersInfo, config) {
         var deferred = $q.defer();
         var url = '/api/tenant/devices?limit=' + pageLink.limit;
         if (angular.isDefined(pageLink.textSearch)) {
@@ -63,15 +65,27 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         if (angular.isDefined(pageLink.textOffset)) {
             url += '&textOffset=' + pageLink.textOffset;
         }
-        $http.get(url, null).then(function success(response) {
-            deferred.resolve(response.data);
+        $http.get(url, config).then(function success(response) {
+            if (applyCustomersInfo) {
+                customerService.applyAssignedCustomersInfo(response.data.data).then(
+                    function success(data) {
+                        response.data.data = data;
+                        deferred.resolve(response.data);
+                    },
+                    function fail() {
+                        deferred.reject();
+                    }
+                );
+            } else {
+                deferred.resolve(response.data);
+            }
         }, function fail() {
             deferred.reject();
         });
         return deferred.promise;
     }
 
-    function getCustomerDevices(customerId, pageLink) {
+    function getCustomerDevices(customerId, pageLink, applyCustomersInfo, config) {
         var deferred = $q.defer();
         var url = '/api/customer/' + customerId + '/devices?limit=' + pageLink.limit;
         if (angular.isDefined(pageLink.textSearch)) {
@@ -83,18 +97,35 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         if (angular.isDefined(pageLink.textOffset)) {
             url += '&textOffset=' + pageLink.textOffset;
         }
-        $http.get(url, null).then(function success(response) {
-            deferred.resolve(response.data);
+        $http.get(url, config).then(function success(response) {
+            if (applyCustomersInfo) {
+                customerService.applyAssignedCustomerInfo(response.data.data, customerId).then(
+                    function success(data) {
+                        response.data.data = data;
+                        deferred.resolve(response.data);
+                    },
+                    function fail() {
+                        deferred.reject();
+                    }
+                );
+            } else {
+                deferred.resolve(response.data);
+            }
         }, function fail() {
             deferred.reject();
         });
+
         return deferred.promise;
     }
 
-    function getDevice(deviceId, ignoreErrors) {
+    function getDevice(deviceId, ignoreErrors, config) {
         var deferred = $q.defer();
         var url = '/api/device/' + deviceId;
-        $http.get(url, { ignoreErrors: ignoreErrors }).then(function success(response) {
+        if (!config) {
+            config = {};
+        }
+        config = Object.assign(config, { ignoreErrors: ignoreErrors });
+        $http.get(url, config).then(function success(response) {
             deferred.resolve(response.data);
         }, function fail(response) {
             deferred.reject(response.data);
@@ -102,7 +133,7 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         return deferred.promise;
     }
 
-    function getDevices(deviceIds) {
+    function getDevices(deviceIds, config) {
         var deferred = $q.defer();
         var ids = '';
         for (var i=0;i<deviceIds.length;i++) {
@@ -112,7 +143,7 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
             ids += deviceIds[i];
         }
         var url = '/api/devices?deviceIds=' + ids;
-        $http.get(url, null).then(function success(response) {
+        $http.get(url, config).then(function success(response) {
             var devices = response.data;
             devices.sort(function (device1, device2) {
                var id1 =  device1.id.id;
@@ -128,16 +159,16 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         return deferred.promise;
     }
 
-    function fetchAliasDeviceByNameFilter(deviceNameFilter, limit) {
+    function fetchAliasDeviceByNameFilter(deviceNameFilter, limit, applyCustomersInfo, config) {
         var deferred = $q.defer();
         var user = userService.getCurrentUser();
         var promise;
         var pageLink = {limit: limit, textSearch: deviceNameFilter};
         if (user.authority === 'CUSTOMER_USER') {
             var customerId = user.customerId;
-            promise = getCustomerDevices(customerId, pageLink);
+            promise = getCustomerDevices(customerId, pageLink, applyCustomersInfo, config);
         } else {
-            promise = getTenantDevices(pageLink);
+            promise = getTenantDevices(pageLink, applyCustomersInfo, config);
         }
         promise.then(
             function success(result) {
@@ -194,7 +225,7 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
                 var deviceFilter = deviceAlias.deviceFilter;
                 if (deviceFilter.useFilter) {
                     var deviceNameFilter = deviceFilter.deviceNameFilter;
-                    fetchAliasDeviceByNameFilter(deviceNameFilter, 100).then(
+                    fetchAliasDeviceByNameFilter(deviceNameFilter, 100, false).then(
                         function(devices) {
                             if (devices && devices != null) {
                                 var resolvedAlias = {alias: alias, deviceId: devices[0].id.id};
@@ -276,7 +307,7 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         var promise;
         if (deviceFilter.useFilter) {
             var deviceNameFilter = deviceFilter.deviceNameFilter;
-            promise = fetchAliasDeviceByNameFilter(deviceNameFilter, 1);
+            promise = fetchAliasDeviceByNameFilter(deviceNameFilter, 1, false);
         } else {
             var deviceList = deviceFilter.deviceList;
             promise = getDevices(deviceList);
@@ -301,8 +332,8 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         var url = '/api/device';
         $http.post(url, device).then(function success(response) {
             deferred.resolve(response.data);
-        }, function fail(response) {
-            deferred.reject(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
@@ -312,8 +343,8 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         var url = '/api/device/' + deviceId;
         $http.delete(url).then(function success() {
             deferred.resolve();
-        }, function fail(response) {
-            deferred.reject(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
@@ -323,8 +354,8 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         var url = '/api/device/' + deviceId + '/credentials';
         $http.get(url, null).then(function success(response) {
             deferred.resolve(response.data);
-        }, function fail(response) {
-            deferred.reject(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
@@ -334,8 +365,8 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         var url = '/api/device/credentials';
         $http.post(url, deviceCredentials).then(function success(response) {
             deferred.resolve(response.data);
-        }, function fail(response) {
-            deferred.reject(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
@@ -343,10 +374,10 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
     function assignDeviceToCustomer(customerId, deviceId) {
         var deferred = $q.defer();
         var url = '/api/customer/' + customerId + '/device/' + deviceId;
-        $http.post(url, null).then(function success() {
-            deferred.resolve();
-        }, function fail(response) {
-            deferred.reject(response.data);
+        $http.post(url, null).then(function success(response) {
+            deferred.resolve(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
@@ -354,10 +385,21 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
     function unassignDeviceFromCustomer(deviceId) {
         var deferred = $q.defer();
         var url = '/api/customer/device/' + deviceId;
-        $http.delete(url).then(function success() {
-            deferred.resolve();
-        }, function fail(response) {
-            deferred.reject(response.data);
+        $http.delete(url).then(function success(response) {
+            deferred.resolve(response.data);
+        }, function fail() {
+            deferred.reject();
+        });
+        return deferred.promise;
+    }
+
+    function makeDevicePublic(deviceId) {
+        var deferred = $q.defer();
+        var url = '/api/customer/public/device/' + deviceId;
+        $http.post(url, null).then(function success(response) {
+            deferred.resolve(response.data);
+        }, function fail() {
+            deferred.reject();
         });
         return deferred.promise;
     }
@@ -425,7 +467,7 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
         }
     }
 
-    function getDeviceAttributes(deviceId, attributeScope, query, successCallback) {
+    function getDeviceAttributes(deviceId, attributeScope, query, successCallback, config) {
         var deferred = $q.defer();
         var subscriptionId = deviceId + attributeScope;
         var das = deviceAttributesSubscriptionMap[subscriptionId];
@@ -445,7 +487,7 @@ function DeviceService($http, $q, $filter, userService, telemetryWebsocketServic
             }
         } else {
             var url = '/api/plugins/telemetry/' + deviceId + '/values/attributes/' + attributeScope;
-            $http.get(url, null).then(function success(response) {
+            $http.get(url, config).then(function success(response) {
                 processDeviceAttributes(response.data, query, deferred, successCallback);
             }, function fail() {
                 deferred.reject();
diff --git a/ui/src/app/api/login.service.js b/ui/src/app/api/login.service.js
index 5213229..272e4df 100644
--- a/ui/src/app/api/login.service.js
+++ b/ui/src/app/api/login.service.js
@@ -25,6 +25,7 @@ function LoginService($http, $q) {
         changePassword: changePassword,
         hasUser: hasUser,
         login: login,
+        publicLogin: publicLogin,
         resetPassword: resetPassword,
         sendResetPasswordLink: sendResetPasswordLink,
     }
@@ -49,6 +50,19 @@ function LoginService($http, $q) {
         return deferred.promise;
     }
 
+    function publicLogin(publicId) {
+        var deferred = $q.defer();
+        var pubilcLoginRequest = {
+            publicId: publicId
+        };
+        $http.post('/api/auth/login/public', pubilcLoginRequest).then(function success(response) {
+            deferred.resolve(response);
+        }, function fail(response) {
+            deferred.reject(response);
+        });
+        return deferred.promise;
+    }
+
     function sendResetPasswordLink(email) {
         var deferred = $q.defer();
         var url = '/api/noauth/resetPasswordByEmail?email=' + email;
diff --git a/ui/src/app/api/subscription.js b/ui/src/app/api/subscription.js
new file mode 100644
index 0000000..afb9a06
--- /dev/null
+++ b/ui/src/app/api/subscription.js
@@ -0,0 +1,670 @@
+/*
+ * Copyright © 2016-2017 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.
+ */
+
+/*
+     options = {
+         type,
+         targetDeviceAliasIds,  // RPC
+         targetDeviceIds,       // RPC
+         datasources,
+         timeWindowConfig,
+         useDashboardTimewindow,
+         legendConfig,
+         decimals,
+         units,
+         callbacks
+    }
+ */
+
+export default class Subscription {
+    constructor(subscriptionContext, options) {
+
+        this.ctx = subscriptionContext;
+        this.type = options.type;
+        this.callbacks = options.callbacks;
+        this.id = this.ctx.utils.guid();
+        this.cafs = {};
+        this.registrations = [];
+
+        if (this.type === this.ctx.types.widgetType.rpc.value) {
+            this.callbacks.rpcStateChanged = this.callbacks.rpcStateChanged || function(){};
+            this.callbacks.onRpcSuccess = this.callbacks.onRpcSuccess || function(){};
+            this.callbacks.onRpcFailed = this.callbacks.onRpcFailed || function(){};
+            this.callbacks.onRpcErrorCleared = this.callbacks.onRpcErrorCleared || function(){};
+
+            this.targetDeviceAliasIds = options.targetDeviceAliasIds;
+            this.targetDeviceIds = options.targetDeviceIds;
+
+            this.targetDeviceAliasId = null;
+            this.targetDeviceId = null;
+
+            this.rpcRejection = null;
+            this.rpcErrorText = null;
+            this.rpcEnabled = false;
+            this.executingRpcRequest = false;
+            this.executingPromises = [];
+            this.initRpc();
+        } else {
+            this.callbacks.onDataUpdated = this.callbacks.onDataUpdated || function(){};
+            this.callbacks.onDataUpdateError = this.callbacks.onDataUpdateError || function(){};
+            this.callbacks.dataLoading = this.callbacks.dataLoading || function(){};
+            this.callbacks.legendDataUpdated = this.callbacks.legendDataUpdated || function(){};
+            this.callbacks.timeWindowUpdated = this.callbacks.timeWindowUpdated || function(){};
+
+            this.datasources = options.datasources;
+            this.datasourceListeners = [];
+            this.data = [];
+            this.hiddenData = [];
+            this.originalTimewindow = null;
+            this.timeWindow = {
+                stDiff: this.ctx.stDiff
+            }
+            this.useDashboardTimewindow = options.useDashboardTimewindow;
+
+            if (this.useDashboardTimewindow) {
+                this.timeWindowConfig = angular.copy(options.dashboardTimewindow);
+            } else {
+                this.timeWindowConfig = angular.copy(options.timeWindowConfig);
+            }
+
+            this.subscriptionTimewindow = null;
+
+            this.units = options.units || '';
+            this.decimals = angular.isDefined(options.decimals) ? options.decimals : 2;
+
+            this.loadingData = false;
+
+            if (options.legendConfig) {
+                this.legendConfig = options.legendConfig;
+                this.legendData = {
+                    keys: [],
+                    data: []
+                };
+                this.displayLegend = true;
+            } else {
+                this.displayLegend = false;
+            }
+            this.caulculateLegendData = this.displayLegend &&
+                this.type === this.ctx.types.widgetType.timeseries.value &&
+                (this.legendConfig.showMin === true ||
+                this.legendConfig.showMax === true ||
+                this.legendConfig.showAvg === true ||
+                this.legendConfig.showTotal === true);
+            this.initDataSubscription();
+        }
+    }
+
+    initDataSubscription() {
+        var dataIndex = 0;
+        for (var i = 0; i < this.datasources.length; i++) {
+            var datasource = this.datasources[i];
+            for (var a = 0; a < datasource.dataKeys.length; a++) {
+                var dataKey = datasource.dataKeys[a];
+                dataKey.pattern = angular.copy(dataKey.label);
+                var datasourceData = {
+                    datasource: datasource,
+                    dataKey: dataKey,
+                    data: []
+                };
+                this.data.push(datasourceData);
+                this.hiddenData.push({data: []});
+                if (this.displayLegend) {
+                    var legendKey = {
+                        dataKey: dataKey,
+                        dataIndex: dataIndex++
+                    };
+                    this.legendData.keys.push(legendKey);
+                    var legendKeyData = {
+                        min: null,
+                        max: null,
+                        avg: null,
+                        total: null,
+                        hidden: false
+                    };
+                    this.legendData.data.push(legendKeyData);
+                }
+            }
+        }
+
+        var subscription = this;
+        var registration;
+
+        if (this.displayLegend) {
+            this.legendData.keys = this.ctx.$filter('orderBy')(this.legendData.keys, '+label');
+            registration = this.ctx.$scope.$watch(
+                function() {
+                    return subscription.legendData.data;
+                },
+                function (newValue, oldValue) {
+                    for(var i = 0; i < newValue.length; i++) {
+                        if(newValue[i].hidden != oldValue[i].hidden) {
+                            subscription.updateDataVisibility(i);
+                        }
+                    }
+                }, true);
+            this.registrations.push(registration);
+        }
+
+        if (this.type === this.ctx.types.widgetType.timeseries.value) {
+            if (this.useDashboardTimewindow) {
+                registration = this.ctx.$scope.$on('dashboardTimewindowChanged', function (event, newDashboardTimewindow) {
+                    if (!angular.equals(subscription.timeWindowConfig, newDashboardTimewindow) && newDashboardTimewindow) {
+                        subscription.timeWindowConfig = angular.copy(newDashboardTimewindow);
+                        subscription.unsubscribe();
+                        subscription.subscribe();
+                    }
+                });
+                this.registrations.push(registration);
+            } else {
+                this.startWatchingTimewindow();
+            }
+        }
+
+        registration = this.ctx.$scope.$on('deviceAliasListChanged', function () {
+            subscription.checkSubscriptions();
+        });
+
+        this.registrations.push(registration);
+    }
+
+    startWatchingTimewindow() {
+        var subscription = this;
+        this.timeWindowWatchRegistration = this.ctx.$scope.$watch(function () {
+            return subscription.timeWindowConfig;
+        }, function (newTimewindow, prevTimewindow) {
+            if (!angular.equals(newTimewindow, prevTimewindow)) {
+                subscription.unsubscribe();
+                subscription.subscribe();
+            }
+        }, true);
+        this.registrations.push(this.timeWindowWatchRegistration);
+    }
+
+    stopWatchingTimewindow() {
+        if (this.timeWindowWatchRegistration) {
+            this.timeWindowWatchRegistration();
+            var index = this.registrations.indexOf(this.timeWindowWatchRegistration);
+            if (index > -1) {
+                this.registrations.splice(index, 1);
+            }
+        }
+    }
+
+    initRpc() {
+
+        if (this.targetDeviceAliasIds && this.targetDeviceAliasIds.length > 0) {
+            this.targetDeviceAliasId = this.targetDeviceAliasIds[0];
+            if (this.ctx.aliasesInfo.deviceAliases[this.targetDeviceAliasId]) {
+                this.targetDeviceId = this.ctx.aliasesInfo.deviceAliases[this.targetDeviceAliasId].deviceId;
+            }
+            var subscription = this;
+            var registration = this.ctx.$scope.$on('deviceAliasListChanged', function () {
+                var deviceId = null;
+                if (subscription.ctx.aliasesInfo.deviceAliases[subscription.targetDeviceAliasId]) {
+                    deviceId = subscription.ctx.aliasesInfo.deviceAliases[subscription.targetDeviceAliasId].deviceId;
+                }
+                if (!angular.equals(deviceId, subscription.targetDeviceId)) {
+                    subscription.targetDeviceId = deviceId;
+                    if (subscription.targetDeviceId) {
+                        subscription.rpcEnabled = true;
+                    } else {
+                        subscription.rpcEnabled = subscription.ctx.$scope.widgetEditMode ? true : false;
+                    }
+                    subscription.callbacks.rpcStateChanged(subscription);
+                }
+            });
+            this.registrations.push(registration);
+        } else if (this.targetDeviceIds && this.targetDeviceIds.length > 0) {
+            this.targetDeviceId = this.targetDeviceIds[0];
+        }
+
+        if (this.targetDeviceId) {
+            this.rpcEnabled = true;
+        } else {
+            this.rpcEnabled = this.ctx.$scope.widgetEditMode ? true : false;
+        }
+        this.callbacks.rpcStateChanged(this);
+    }
+
+    clearRpcError() {
+        this.rpcRejection = null;
+        this.rpcErrorText = null;
+        this.callbacks.onRpcErrorCleared(this);
+    }
+
+    sendOneWayCommand(method, params, timeout) {
+        return this.sendCommand(true, method, params, timeout);
+    }
+
+    sendTwoWayCommand(method, params, timeout) {
+        return this.sendCommand(false, method, params, timeout);
+    }
+
+    sendCommand(oneWayElseTwoWay, method, params, timeout) {
+        if (!this.rpcEnabled) {
+            return this.ctx.$q.reject();
+        }
+
+        if (this.rpcRejection && this.rpcRejection.status !== 408) {
+            this.rpcRejection = null;
+            this.rpcErrorText = null;
+            this.callbacks.onRpcErrorCleared(this);
+        }
+
+        var subscription = this;
+
+        var requestBody = {
+            method: method,
+            params: params
+        };
+
+        if (timeout && timeout > 0) {
+            requestBody.timeout = timeout;
+        }
+
+        var deferred = this.ctx.$q.defer();
+        this.executingRpcRequest = true;
+        this.callbacks.rpcStateChanged(this);
+        if (this.ctx.$scope.widgetEditMode) {
+            this.ctx.$timeout(function() {
+                subscription.executingRpcRequest = false;
+                subscription.callbacks.rpcStateChanged(subscription);
+                if (oneWayElseTwoWay) {
+                    deferred.resolve();
+                } else {
+                    deferred.resolve(requestBody);
+                }
+            }, 500);
+        } else {
+            this.executingPromises.push(deferred.promise);
+            var targetSendFunction = oneWayElseTwoWay ? this.ctx.deviceService.sendOneWayRpcCommand : this.ctx.deviceService.sendTwoWayRpcCommand;
+            targetSendFunction(this.targetDeviceId, requestBody).then(
+                function success(responseBody) {
+                    subscription.rpcRejection = null;
+                    subscription.rpcErrorText = null;
+                    var index = subscription.executingPromises.indexOf(deferred.promise);
+                    if (index >= 0) {
+                        subscription.executingPromises.splice( index, 1 );
+                    }
+                    subscription.executingRpcRequest = subscription.executingPromises.length > 0;
+                    subscription.callbacks.onRpcSuccess(subscription);
+                    deferred.resolve(responseBody);
+                },
+                function fail(rejection) {
+                    var index = subscription.executingPromises.indexOf(deferred.promise);
+                    if (index >= 0) {
+                        subscription.executingPromises.splice( index, 1 );
+                    }
+                    subscription.executingRpcRequest = subscription.executingPromises.length > 0;
+                    subscription.callbacks.rpcStateChanged(subscription);
+                    if (!subscription.executingRpcRequest || rejection.status === 408) {
+                        subscription.rpcRejection = rejection;
+                        if (rejection.status === 408) {
+                            subscription.rpcErrorText = 'Device is offline.';
+                        } else {
+                            subscription.rpcErrorText =  'Error : ' + rejection.status + ' - ' + rejection.statusText;
+                            if (rejection.data && rejection.data.length > 0) {
+                                subscription.rpcErrorText += '</br>';
+                                subscription.rpcErrorText += rejection.data;
+                            }
+                        }
+                        subscription.callbacks.onRpcFailed(subscription);
+                    }
+                    deferred.reject(rejection);
+                }
+            );
+        }
+        return deferred.promise;
+    }
+
+    updateDataVisibility(index) {
+        var hidden = this.legendData.data[index].hidden;
+        if (hidden) {
+            this.hiddenData[index].data = this.data[index].data;
+            this.data[index].data = [];
+        } else {
+            this.data[index].data = this.hiddenData[index].data;
+            this.hiddenData[index].data = [];
+        }
+        this.onDataUpdated();
+    }
+
+    onDataUpdated(apply) {
+        if (this.cafs['dataUpdated']) {
+            this.cafs['dataUpdated']();
+            this.cafs['dataUpdated'] = null;
+        }
+        var subscription = this;
+        this.cafs['dataUpdated'] = this.ctx.tbRaf(function() {
+            try {
+                subscription.callbacks.onDataUpdated(subscription, apply);
+            } catch (e) {
+                subscription.callbacks.onDataUpdateError(subscription, e);
+            }
+        });
+        if (apply) {
+            this.ctx.$scope.$digest();
+        }
+    }
+
+    updateTimewindowConfig(newTimewindow) {
+        this.timeWindowConfig = newTimewindow;
+    }
+
+    onResetTimewindow() {
+        if (this.useDashboardTimewindow) {
+            this.ctx.dashboardTimewindowApi.onResetTimewindow();
+        } else {
+            if (this.originalTimewindow) {
+                this.stopWatchingTimewindow();
+                this.timeWindowConfig = angular.copy(this.originalTimewindow);
+                this.originalTimewindow = null;
+                this.callbacks.timeWindowUpdated(this, this.timeWindowConfig);
+                this.unsubscribe();
+                this.subscribe();
+                this.startWatchingTimewindow();
+            }
+        }
+    }
+
+    onUpdateTimewindow(startTimeMs, endTimeMs) {
+        if (this.useDashboardTimewindow) {
+            this.ctx.dashboardTimewindowApi.onUpdateTimewindow(startTimeMs, endTimeMs);
+        } else {
+            this.stopWatchingTimewindow();
+            if (!this.originalTimewindow) {
+                this.originalTimewindow = angular.copy(this.timeWindowConfig);
+            }
+            this.timeWindowConfig = this.ctx.timeService.toHistoryTimewindow(this.timeWindowConfig, startTimeMs, endTimeMs);
+            this.callbacks.timeWindowUpdated(this, this.timeWindowConfig);
+            this.unsubscribe();
+            this.subscribe();
+            this.startWatchingTimewindow();
+        }
+    }
+
+    notifyDataLoading() {
+        this.loadingData = true;
+        this.callbacks.dataLoading(this);
+    }
+
+    notifyDataLoaded() {
+        this.loadingData = false;
+        this.callbacks.dataLoading(this);
+    }
+
+    updateTimewindow() {
+        this.timeWindow.interval = this.subscriptionTimewindow.aggregation.interval || 1000;
+        if (this.subscriptionTimewindow.realtimeWindowMs) {
+            this.timeWindow.maxTime = (new Date).getTime() + this.timeWindow.stDiff;
+            this.timeWindow.minTime = this.timeWindow.maxTime - this.subscriptionTimewindow.realtimeWindowMs;
+        } else if (this.subscriptionTimewindow.fixedWindow) {
+            this.timeWindow.maxTime = this.subscriptionTimewindow.fixedWindow.endTimeMs;
+            this.timeWindow.minTime = this.subscriptionTimewindow.fixedWindow.startTimeMs;
+        }
+    }
+
+    updateRealtimeSubscription(subscriptionTimewindow) {
+        if (subscriptionTimewindow) {
+            this.subscriptionTimewindow = subscriptionTimewindow;
+        } else {
+            this.subscriptionTimewindow =
+                this.ctx.timeService.createSubscriptionTimewindow(
+                    this.timeWindowConfig,
+                    this.timeWindow.stDiff);
+        }
+        this.updateTimewindow();
+        return this.subscriptionTimewindow;
+    }
+
+    dataUpdated(sourceData, datasourceIndex, dataKeyIndex, apply) {
+        this.notifyDataLoaded();
+        var update = true;
+        var currentData;
+        if (this.displayLegend && this.legendData.data[datasourceIndex + dataKeyIndex].hidden) {
+            currentData = this.hiddenData[datasourceIndex + dataKeyIndex];
+        } else {
+            currentData = this.data[datasourceIndex + dataKeyIndex];
+        }
+        if (this.type === this.ctx.types.widgetType.latest.value) {
+            var prevData = currentData.data;
+            if (prevData && prevData[0] && prevData[0].length > 1 && sourceData.data.length > 0) {
+                var prevValue = prevData[0][1];
+                if (prevValue === sourceData.data[0][1]) {
+                    update = false;
+                }
+            }
+        }
+        if (update) {
+            if (this.subscriptionTimewindow && this.subscriptionTimewindow.realtimeWindowMs) {
+                this.updateTimewindow();
+            }
+            currentData.data = sourceData.data;
+            if (this.caulculateLegendData) {
+                this.updateLegend(datasourceIndex + dataKeyIndex, sourceData.data, apply);
+            }
+            this.onDataUpdated(apply);
+        }
+    }
+
+    updateLegend(dataIndex, data, apply) {
+        var legendKeyData = this.legendData.data[dataIndex];
+        if (this.legendConfig.showMin) {
+            legendKeyData.min = this.ctx.widgetUtils.formatValue(calculateMin(data), this.decimals, this.units);
+        }
+        if (this.legendConfig.showMax) {
+            legendKeyData.max = this.ctx.widgetUtils.formatValue(calculateMax(data), this.decimals, this.units);
+        }
+        if (this.legendConfig.showAvg) {
+            legendKeyData.avg = this.ctx.widgetUtils.formatValue(calculateAvg(data), this.decimals, this.units);
+        }
+        if (this.legendConfig.showTotal) {
+            legendKeyData.total = this.ctx.widgetUtils.formatValue(calculateTotal(data), this.decimals, this.units);
+        }
+        this.callbacks.legendDataUpdated(this, apply !== false);
+    }
+
+    subscribe() {
+        if (this.type === this.ctx.types.widgetType.rpc.value) {
+            return;
+        }
+        this.notifyDataLoading();
+        if (this.type === this.ctx.types.widgetType.timeseries.value && this.timeWindowConfig) {
+            this.updateRealtimeSubscription();
+            if (this.subscriptionTimewindow.fixedWindow) {
+                this.onDataUpdated();
+            }
+        }
+        var index = 0;
+        for (var i = 0; i < this.datasources.length; i++) {
+            var datasource = this.datasources[i];
+            if (angular.isFunction(datasource))
+                continue;
+            var deviceId = null;
+            if (datasource.type === this.ctx.types.datasourceType.device) {
+                var aliasName = null;
+                var deviceName = null;
+                if (datasource.deviceId) {
+                    deviceId = datasource.deviceId;
+                    datasource.name = datasource.deviceName;
+                    aliasName = datasource.deviceName;
+                    deviceName = datasource.deviceName;
+                } else if (datasource.deviceAliasId && this.ctx.aliasesInfo.deviceAliases[datasource.deviceAliasId]) {
+                    deviceId = this.ctx.aliasesInfo.deviceAliases[datasource.deviceAliasId].deviceId;
+                    datasource.name = this.ctx.aliasesInfo.deviceAliases[datasource.deviceAliasId].alias;
+                    aliasName = this.ctx.aliasesInfo.deviceAliases[datasource.deviceAliasId].alias;
+                    deviceName = '';
+                    var devicesInfo = this.ctx.aliasesInfo.deviceAliasesInfo[datasource.deviceAliasId];
+                    for (var d = 0; d < devicesInfo.length; d++) {
+                        if (devicesInfo[d].id === deviceId) {
+                            deviceName = devicesInfo[d].name;
+                            break;
+                        }
+                    }
+                }
+            } else {
+                datasource.name = datasource.name || this.ctx.types.datasourceType.function;
+            }
+            for (var dk = 0; dk < datasource.dataKeys.length; dk++) {
+                updateDataKeyLabel(datasource.dataKeys[dk], datasource.name, deviceName, aliasName);
+            }
+
+            var subscription = this;
+
+            var listener = {
+                subscriptionType: this.type,
+                subscriptionTimewindow: this.subscriptionTimewindow,
+                datasource: datasource,
+                deviceId: deviceId,
+                dataUpdated: function (data, datasourceIndex, dataKeyIndex, apply) {
+                    subscription.dataUpdated(data, datasourceIndex, dataKeyIndex, apply);
+                },
+                updateRealtimeSubscription: function () {
+                    this.subscriptionTimewindow = subscription.updateRealtimeSubscription();
+                    return this.subscriptionTimewindow;
+                },
+                setRealtimeSubscription: function (subscriptionTimewindow) {
+                    subscription.updateRealtimeSubscription(angular.copy(subscriptionTimewindow));
+                },
+                datasourceIndex: index
+            };
+
+            for (var a = 0; a < datasource.dataKeys.length; a++) {
+                this.data[index + a].data = [];
+            }
+
+            index += datasource.dataKeys.length;
+
+            this.datasourceListeners.push(listener);
+            this.ctx.datasourceService.subscribeToDatasource(listener);
+        }
+    }
+
+    unsubscribe() {
+        if (this.type !== this.ctx.types.widgetType.rpc.value) {
+            for (var i = 0; i < this.datasourceListeners.length; i++) {
+                var listener = this.datasourceListeners[i];
+                this.ctx.datasourceService.unsubscribeFromDatasource(listener);
+            }
+            this.datasourceListeners = [];
+        }
+    }
+
+    checkSubscriptions() {
+        var subscriptionsChanged = false;
+        for (var i = 0; i < this.datasourceListeners.length; i++) {
+            var listener = this.datasourceListeners[i];
+            var deviceId = null;
+            var aliasName = null;
+            if (listener.datasource.type === this.ctx.types.datasourceType.device) {
+                if (listener.datasource.deviceAliasId &&
+                    this.ctx.aliasesInfo.deviceAliases[listener.datasource.deviceAliasId]) {
+                    deviceId = this.ctx.aliasesInfo.deviceAliases[listener.datasource.deviceAliasId].deviceId;
+                    aliasName = this.ctx.aliasesInfo.deviceAliases[listener.datasource.deviceAliasId].alias;
+                }
+                if (!angular.equals(deviceId, listener.deviceId) ||
+                    !angular.equals(aliasName, listener.datasource.name)) {
+                    subscriptionsChanged = true;
+                    break;
+                }
+            }
+        }
+        if (subscriptionsChanged) {
+            this.unsubscribe();
+            this.subscribe();
+        }
+    }
+
+    destroy() {
+        this.unsubscribe();
+        for (var cafId in this.cafs) {
+            if (this.cafs[cafId]) {
+                this.cafs[cafId]();
+                this.cafs[cafId] = null;
+            }
+        }
+        this.registrations.forEach(function (registration) {
+            registration();
+        });
+        this.registrations = [];
+    }
+
+}
+
+const varsRegex = /\$\{([^\}]*)\}/g;
+
+function updateDataKeyLabel(dataKey, dsName, deviceName, aliasName) {
+    var pattern = dataKey.pattern;
+    var label = dataKey.pattern;
+    var match = varsRegex.exec(pattern);
+    while (match !== null) {
+        var variable = match[0];
+        var variableName = match[1];
+        if (variableName === 'dsName') {
+            label = label.split(variable).join(dsName);
+        } else if (variableName === 'deviceName') {
+            label = label.split(variable).join(deviceName);
+        } else if (variableName === 'aliasName') {
+            label = label.split(variable).join(aliasName);
+        }
+        match = varsRegex.exec(pattern);
+    }
+    dataKey.label = label;
+}
+
+function calculateMin(data) {
+    if (data.length > 0) {
+        var result = Number(data[0][1]);
+        for (var i=1;i<data.length;i++) {
+            result = Math.min(result, Number(data[i][1]));
+        }
+        return result;
+    } else {
+        return null;
+    }
+}
+
+function calculateMax(data) {
+    if (data.length > 0) {
+        var result = Number(data[0][1]);
+        for (var i=1;i<data.length;i++) {
+            result = Math.max(result, Number(data[i][1]));
+        }
+        return result;
+    } else {
+        return null;
+    }
+}
+
+function calculateAvg(data) {
+    if (data.length > 0) {
+        return calculateTotal(data)/data.length;
+    } else {
+        return null;
+    }
+}
+
+function calculateTotal(data) {
+    if (data.length > 0) {
+        var result = 0;
+        for (var i = 0; i < data.length; i++) {
+            result += Number(data[i][1]);
+        }
+        return result;
+    } else {
+        return null;
+    }
+}
diff --git a/ui/src/app/api/telemetry-websocket.service.js b/ui/src/app/api/telemetry-websocket.service.js
index e03aafa..e69c264 100644
--- a/ui/src/app/api/telemetry-websocket.service.js
+++ b/ui/src/app/api/telemetry-websocket.service.js
@@ -45,15 +45,21 @@ function TelemetryWebsocketService($rootScope, $websocket, $timeout, $window, ty
         socketCloseTimer,
         reconnectTimer;
 
+    var port = location.port;
     if (location.protocol === "https:") {
+        if (!port) {
+            port = "443";
+        }
         telemetryUri = "wss:";
     } else {
+        if (!port) {
+            port = "80";
+        }
         telemetryUri = "ws:";
     }
-    telemetryUri += "//" + location.hostname + ":" + location.port;
+    telemetryUri += "//" + location.hostname + ":" + port;
     telemetryUri += "/api/ws/plugins/telemetry";
 
-
     var service = {
         subscribe: subscribe,
         unsubscribe: unsubscribe
diff --git a/ui/src/app/api/user.service.js b/ui/src/app/api/user.service.js
index ad401df..4dc41f7 100644
--- a/ui/src/app/api/user.service.js
+++ b/ui/src/app/api/user.service.js
@@ -22,9 +22,10 @@ export default angular.module('thingsboard.api.user', [thingsboardApiLogin,
     .name;
 
 /*@ngInject*/
-function UserService($http, $q, $rootScope, adminService, dashboardService, toast, store, jwtHelper, $translate, $state) {
+function UserService($http, $q, $rootScope, adminService, dashboardService, loginService, toast, store, jwtHelper, $translate, $state, $location) {
     var currentUser = null,
         currentUserDetails = null,
+        lastPublicDashboardId = null,
         allowedDashboardIds = [],
         userLoaded = false;
 
@@ -33,6 +34,9 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
     var service = {
         deleteUser: deleteUser,
         getAuthority: getAuthority,
+        isPublic: isPublic,
+        getPublicId: getPublicId,
+        parsePublicId: parsePublicId,
         isAuthenticated: isAuthenticated,
         getCurrentUser: getCurrentUser,
         getCustomerUsers: getCustomerUsers,
@@ -51,18 +55,25 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
         updateAuthorizationHeader: updateAuthorizationHeader,
         gotoDefaultPlace: gotoDefaultPlace,
         forceDefaultPlace: forceDefaultPlace,
-        logout: logout
+        updateLastPublicDashboardId: updateLastPublicDashboardId,
+        logout: logout,
+        reloadUser: reloadUser
     }
 
-    loadUser(true).then(function success() {
-        notifyUserLoaded();
-    }, function fail() {
-        notifyUserLoaded();
-    });
+    reloadUser();
 
     return service;
 
-    function updateAndValidateToken(token, prefix) {
+    function reloadUser() {
+        userLoaded = false;
+        loadUser(true).then(function success() {
+            notifyUserLoaded();
+        }, function fail() {
+            notifyUserLoaded();
+        });
+    }
+
+    function updateAndValidateToken(token, prefix, notify) {
         var valid = false;
         var tokenData = jwtHelper.decodeToken(token);
         var issuedAt = tokenData.iat;
@@ -76,7 +87,7 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
                 valid = true;
             }
         }
-        if (!valid) {
+        if (!valid && notify) {
             $rootScope.$broadcast('unauthenticated');
         }
     }
@@ -91,6 +102,7 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
     function setUserFromJwtToken(jwtToken, refreshToken, notify, doLogout) {
         currentUser = null;
         currentUserDetails = null;
+        lastPublicDashboardId = null;
         allowedDashboardIds = [];
         if (!jwtToken) {
             clearTokenData();
@@ -98,8 +110,8 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
                 $rootScope.$broadcast('unauthenticated', doLogout);
             }
         } else {
-            updateAndValidateToken(jwtToken, 'jwt_token');
-            updateAndValidateToken(refreshToken, 'refresh_token');
+            updateAndValidateToken(jwtToken, 'jwt_token', true);
+            updateAndValidateToken(refreshToken, 'refresh_token', true);
             if (notify) {
                 loadUser(false).then(function success() {
                     $rootScope.$broadcast('authenticated');
@@ -213,13 +225,58 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
         }
     }
 
+    function isPublic() {
+        if (currentUser) {
+            return currentUser.isPublic;
+        } else {
+            return false;
+        }
+    }
+
+    function getPublicId() {
+        if (isPublic()) {
+            return currentUser.sub;
+        } else {
+            return null;
+        }
+    }
+
+    function parsePublicId() {
+        var token = getJwtToken();
+        if (token) {
+            var tokenData = jwtHelper.decodeToken(token);
+            if (tokenData && tokenData.isPublic) {
+                return tokenData.sub;
+            }
+        }
+        return null;
+    }
+
     function isUserLoaded() {
         return userLoaded;
     }
 
     function loadUser(doTokenRefresh) {
+
         var deferred = $q.defer();
-        if (!currentUser) {
+
+        function fetchAllowedDashboardIds() {
+            var pageLink = {limit: 100};
+            dashboardService.getCustomerDashboards(currentUser.customerId, pageLink).then(
+                function success(result) {
+                    var dashboards = result.data;
+                    for (var d=0;d<dashboards.length;d++) {
+                        allowedDashboardIds.push(dashboards[d].id.id);
+                    }
+                    deferred.resolve();
+                },
+                function fail() {
+                    deferred.reject();
+                }
+            );
+        }
+
+        function procceedJwtTokenValidate() {
             validateJwtToken(doTokenRefresh).then(function success() {
                 var jwtToken = store.get('jwt_token');
                 currentUser = jwtHelper.decodeToken(jwtToken);
@@ -228,29 +285,19 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
                 } else if (currentUser) {
                     currentUser.authority = "ANONYMOUS";
                 }
-                if (currentUser.userId) {
+                if (currentUser.isPublic) {
+                    $rootScope.forceFullscreen = true;
+                    fetchAllowedDashboardIds();
+                } else if (currentUser.userId) {
                     getUser(currentUser.userId).then(
                         function success(user) {
                             currentUserDetails = user;
                             $rootScope.forceFullscreen = false;
-                            if (currentUserDetails.additionalInfo &&
-                                currentUserDetails.additionalInfo.defaultDashboardFullscreen) {
-                                $rootScope.forceFullscreen = currentUserDetails.additionalInfo.defaultDashboardFullscreen === true;
+                            if (userForceFullscreen()) {
+                                $rootScope.forceFullscreen = true;
                             }
                             if ($rootScope.forceFullscreen && currentUser.authority === 'CUSTOMER_USER') {
-                                var pageLink = {limit: 100};
-                                dashboardService.getCustomerDashboards(currentUser.customerId, pageLink).then(
-                                    function success(result) {
-                                        var dashboards = result.data;
-                                        for (var d=0;d<dashboards.length;d++) {
-                                            allowedDashboardIds.push(dashboards[d].id.id);
-                                        }
-                                        deferred.resolve();
-                                    },
-                                    function fail() {
-                                        deferred.reject();
-                                    }
-                                );
+                                fetchAllowedDashboardIds();
                             } else {
                                 deferred.resolve();
                             }
@@ -265,6 +312,23 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
             }, function fail() {
                 deferred.reject();
             });
+        }
+
+        if (!currentUser) {
+            var locationSearch = $location.search();
+            if (locationSearch.publicId) {
+                loginService.publicLogin(locationSearch.publicId).then(function success(response) {
+                    var token = response.data.token;
+                    var refreshToken = response.data.refreshToken;
+                    updateAndValidateToken(token, 'jwt_token', false);
+                    updateAndValidateToken(refreshToken, 'refresh_token', false);
+                    procceedJwtTokenValidate();
+                }, function fail() {
+                    deferred.reject();
+                });
+            } else {
+                procceedJwtTokenValidate();
+            }
         } else {
             deferred.resolve();
         }
@@ -373,17 +437,17 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
     function forceDefaultPlace(to, params) {
         if (currentUser && isAuthenticated()) {
             if (currentUser.authority === 'CUSTOMER_USER') {
-                if (currentUserDetails &&
-                    currentUserDetails.additionalInfo &&
-                    currentUserDetails.additionalInfo.defaultDashboardId) {
-                    if ($rootScope.forceFullscreen) {
-                        if (to.name === 'home.profile') {
-                            return false;
-                        } else if (to.name === 'home.dashboards.dashboard' && allowedDashboardIds.indexOf(params.dashboardId) > -1) {
+                if ((userHasDefaultDashboard() && $rootScope.forceFullscreen) || isPublic()) {
+                    if (to.name === 'home.profile') {
+                        if (userHasProfile()) {
                             return false;
                         } else {
                             return true;
                         }
+                    } else if (to.name === 'home.dashboards.dashboard' && allowedDashboardIds.indexOf(params.dashboardId) > -1) {
+                        return false;
+                    } else {
+                        return true;
                     }
                 }
             }
@@ -395,11 +459,12 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
         if (currentUser && isAuthenticated()) {
             var place = 'home.links';
             if (currentUser.authority === 'CUSTOMER_USER') {
-                if (currentUserDetails &&
-                    currentUserDetails.additionalInfo &&
-                    currentUserDetails.additionalInfo.defaultDashboardId) {
+                if (userHasDefaultDashboard()) {
                     place = 'home.dashboards.dashboard';
                     params = {dashboardId: currentUserDetails.additionalInfo.defaultDashboardId};
+                } else if (isPublic()) {
+                    place = 'home.dashboards.dashboard';
+                    params = {dashboardId: lastPublicDashboardId};
                 }
             } else if (currentUser.authority === 'SYS_ADMIN') {
                 adminService.checkUpdates().then(
@@ -416,4 +481,27 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, toas
         }
     }
 
+    function userHasDefaultDashboard() {
+        return currentUserDetails &&
+               currentUserDetails.additionalInfo &&
+               currentUserDetails.additionalInfo.defaultDashboardId;
+    }
+
+    function userForceFullscreen() {
+        return (currentUser && currentUser.isPublic) ||
+               (currentUserDetails.additionalInfo &&
+                currentUserDetails.additionalInfo.defaultDashboardFullscreen &&
+                currentUserDetails.additionalInfo.defaultDashboardFullscreen === true);
+    }
+
+    function userHasProfile() {
+        return currentUser && !currentUser.isPublic;
+    }
+
+    function updateLastPublicDashboardId(dashboardId) {
+        if (isPublic()) {
+            lastPublicDashboardId = dashboardId;
+        }
+    }
+
 }
diff --git a/ui/src/app/api/widget.service.js b/ui/src/app/api/widget.service.js
index fb8565c..21c38e2 100644
--- a/ui/src/app/api/widget.service.js
+++ b/ui/src/app/api/widget.service.js
@@ -17,7 +17,8 @@ import $ from 'jquery';
 import moment from 'moment';
 import tinycolor from 'tinycolor2';
 
-import thinsboardLedLight from '../components/led-light.directive';
+import thingsboardLedLight from '../components/led-light.directive';
+import thingsboardTimeseriesTableWidget from '../widget/lib/timeseries-table-widget';
 
 import TbFlot from '../widget/lib/flot-widget';
 import TbAnalogueLinearGauge from '../widget/lib/analogue-linear-gauge';
@@ -31,7 +32,8 @@ import cssjs from '../../vendor/css.js/css';
 import thingsboardTypes from '../common/types.constant';
 import thingsboardUtils from '../common/utils.service';
 
-export default angular.module('thingsboard.api.widget', ['oc.lazyLoad', thinsboardLedLight, thingsboardTypes, thingsboardUtils])
+export default angular.module('thingsboard.api.widget', ['oc.lazyLoad', thingsboardLedLight, thingsboardTimeseriesTableWidget,
+    thingsboardTypes, thingsboardUtils])
     .factory('widgetService', WidgetService)
     .name;
 
@@ -539,6 +541,10 @@ function WidgetService($rootScope, $http, $q, $filter, $ocLazyLoad, $window, typ
 
          '    }\n\n' +
 
+         '    self.useCustomDatasources = function() {\n\n' +
+
+         '    }\n\n' +
+
          '    self.onResize = function() {\n\n' +
 
          '    }\n\n' +
@@ -579,6 +585,11 @@ function WidgetService($rootScope, $http, $q, $filter, $ocLazyLoad, $window, typ
             if (angular.isFunction(widgetTypeInstance.getDataKeySettingsSchema)) {
                 result.dataKeySettingsSchema = widgetTypeInstance.getDataKeySettingsSchema();
             }
+            if (angular.isFunction(widgetTypeInstance.useCustomDatasources)) {
+                result.useCustomDatasources = widgetTypeInstance.useCustomDatasources();
+            } else {
+                result.useCustomDatasources = false;
+            }
             return result;
         } catch (e) {
             utils.processWidgetException(e);
@@ -617,6 +628,7 @@ function WidgetService($rootScope, $http, $q, $filter, $ocLazyLoad, $window, typ
                     if (widgetType.dataKeySettingsSchema) {
                         widgetInfo.typeDataKeySettingsSchema = widgetType.dataKeySettingsSchema;
                     }
+                    widgetInfo.useCustomDatasources = widgetType.useCustomDatasources;
                     putWidgetInfoToCache(widgetInfo, bundleAlias, widgetInfo.alias, isSystem);
                     putWidgetTypeFunctionToCache(widgetType.widgetTypeFunction, bundleAlias, widgetInfo.alias, isSystem);
                     deferred.resolve(widgetInfo);
diff --git a/ui/src/app/app.config.js b/ui/src/app/app.config.js
index 9b2ad25..659f53e 100644
--- a/ui/src/app/app.config.js
+++ b/ui/src/app/app.config.js
@@ -16,6 +16,7 @@
 import injectTapEventPlugin from 'react-tap-event-plugin';
 import UrlHandler from './url.handler';
 import addLocaleKorean from './locale/locale.constant-ko';
+import addLocaleChinese from './locale/locale.constant-zh';
 
 /* eslint-disable import/no-unresolved, import/default */
 
@@ -50,11 +51,15 @@ export default function AppConfig($provide,
     $translateProvider.addInterpolation('$translateMessageFormatInterpolation');
 
     addLocaleKorean(locales);
+    addLocaleChinese(locales);
     var $window = angular.injector(['ng']).get('$window');
     var lang = $window.navigator.language || $window.navigator.userLanguage;
     if (lang === 'ko') {
         $translateProvider.useSanitizeValueStrategy(null);
         $translateProvider.preferredLanguage('ko_KR');
+    } else if (lang === 'zh') {
+        $translateProvider.useSanitizeValueStrategy(null);
+        $translateProvider.preferredLanguage('zh_CN');
     }
 
     for (var langKey in locales) {
diff --git a/ui/src/app/app.js b/ui/src/app/app.js
index e3b36e3..290e9cd 100644
--- a/ui/src/app/app.js
+++ b/ui/src/app/app.js
@@ -19,6 +19,7 @@ import angular from 'angular';
 import ngMaterial from 'angular-material';
 import ngMdIcons from 'angular-material-icons';
 import ngCookies from 'angular-cookies';
+import angularSocialshare from 'angular-socialshare';
 import 'angular-translate';
 import 'angular-translate-loader-static-files';
 import 'angular-translate-storage-local';
@@ -82,6 +83,7 @@ angular.module('thingsboard', [
     ngMaterial,
     ngMdIcons,
     ngCookies,
+    angularSocialshare,
     'pascalprecht.translate',
     'mdColorPicker',
     mdPickers,
diff --git a/ui/src/app/app.run.js b/ui/src/app/app.run.js
index 02fe8fd..75b16fd 100644
--- a/ui/src/app/app.run.js
+++ b/ui/src/app/app.run.js
@@ -55,8 +55,39 @@ export default function AppRun($rootScope, $window, $injector, $location, $log, 
         });
 
         $rootScope.stateChangeStartHandle = $rootScope.$on('$stateChangeStart', function (evt, to, params) {
+
+            function waitForUserLoaded() {
+                if ($rootScope.userLoadedHandle) {
+                    $rootScope.userLoadedHandle();
+                }
+                $rootScope.userLoadedHandle = $rootScope.$on('userLoaded', function () {
+                    $rootScope.userLoadedHandle();
+                    $state.go(to.name, params);
+                });
+            }
+
+            function reloadUserFromPublicId() {
+                userService.setUserFromJwtToken(null, null, false);
+                waitForUserLoaded();
+                userService.reloadUser();
+            }
+
+            var locationSearch = $location.search();
+            var publicId = locationSearch.publicId;
+
             if (userService.isUserLoaded() === true) {
                 if (userService.isAuthenticated()) {
+                    if (userService.isPublic()) {
+                        if (userService.parsePublicId() !== publicId) {
+                            evt.preventDefault();
+                            if (publicId && publicId.length > 0) {
+                                reloadUserFromPublicId();
+                            } else {
+                                userService.logout();
+                            }
+                            return;
+                        }
+                    }
                     if (userService.forceDefaultPlace(to, params)) {
                         evt.preventDefault();
                         gotoDefaultPlace(params);
@@ -75,7 +106,10 @@ export default function AppRun($rootScope, $window, $injector, $location, $log, 
                         }
                     }
                 } else {
-                    if (to.module === 'private') {
+                    if (publicId && publicId.length > 0) {
+                        evt.preventDefault();
+                        reloadUserFromPublicId();
+                    } else if (to.module === 'private') {
                         evt.preventDefault();
                         if (to.url === '/home' || to.url === '/') {
                             $state.go('login', params);
@@ -86,19 +120,17 @@ export default function AppRun($rootScope, $window, $injector, $location, $log, 
                 }
             } else {
                 evt.preventDefault();
-                if ($rootScope.userLoadedHandle) {
-                    $rootScope.userLoadedHandle();
-                }
-                $rootScope.userLoadedHandle = $rootScope.$on('userLoaded', function () {
-                    $rootScope.userLoadedHandle();
-                    $state.go(to.name, params);
-                });
+                waitForUserLoaded();
             }
         })
 
         $rootScope.pageTitle = 'Thingsboard';
 
-        $rootScope.stateChangeSuccessHandle = $rootScope.$on('$stateChangeSuccess', function (evt, to) {
+        $rootScope.stateChangeSuccessHandle = $rootScope.$on('$stateChangeSuccess', function (evt, to, params) {
+            if (userService.isPublic() && to.name === 'home.dashboards.dashboard') {
+                $location.search('publicId', userService.getPublicId());
+                userService.updateLastPublicDashboardId(params.dashboardId);
+            }
             if (angular.isDefined(to.data.pageTitle)) {
                 $translate(to.data.pageTitle).then(function (translation) {
                     $rootScope.pageTitle = 'Thingsboard | ' + translation;
diff --git a/ui/src/app/common/utils.service.js b/ui/src/app/common/utils.service.js
index 3fc5202..6e05bbb 100644
--- a/ui/src/app/common/utils.service.js
+++ b/ui/src/app/common/utils.service.js
@@ -22,7 +22,7 @@ export default angular.module('thingsboard.utils', [thingsboardTypes])
     .name;
 
 /*@ngInject*/
-function Utils($mdColorPalette, $rootScope, $window, types) {
+function Utils($mdColorPalette, $rootScope, $window, $q, deviceService, types) {
 
     var predefinedFunctions = {},
         predefinedFunctionsList = [],
@@ -104,7 +104,10 @@ function Utils($mdColorPalette, $rootScope, $window, types) {
         parseException: parseException,
         processWidgetException: processWidgetException,
         isDescriptorSchemaNotEmpty: isDescriptorSchemaNotEmpty,
-        filterSearchTextEntities: filterSearchTextEntities
+        filterSearchTextEntities: filterSearchTextEntities,
+        guid: guid,
+        createDatasoucesFromSubscriptionsInfo: createDatasoucesFromSubscriptionsInfo,
+        isLocalUrl: isLocalUrl
     }
 
     return service;
@@ -276,4 +279,165 @@ function Utils($mdColorPalette, $rootScope, $window, types) {
         deferred.resolve(response);
     }
 
+    function guid() {
+        function s4() {
+            return Math.floor((1 + Math.random()) * 0x10000)
+                .toString(16)
+                .substring(1);
+        }
+        return s4() + s4() + '-' + s4() + '-' + s4() + '-' +
+            s4() + '-' + s4() + s4() + s4();
+    }
+
+    function genNextColor(datasources) {
+        var index = 0;
+        if (datasources) {
+            for (var i = 0; i < datasources.length; i++) {
+                var datasource = datasources[i];
+                index += datasource.dataKeys.length;
+            }
+        }
+        return getMaterialColor(index);
+    }
+
+    /*var defaultDataKey = {
+        name: 'f(x)',
+        type: types.dataKeyType.function,
+        label: 'Sin',
+        color: getMaterialColor(0),
+        funcBody: getPredefinedFunctionBody('Sin'),
+        settings: {},
+        _hash: Math.random()
+    };
+
+    var defaultDatasource = {
+        type: types.datasourceType.function,
+        name: types.datasourceType.function,
+        dataKeys: [angular.copy(defaultDataKey)]
+    };*/
+
+    function createKey(keyInfo, type, datasources) {
+        var dataKey = {
+            name: keyInfo.name,
+            type: type,
+            label: keyInfo.label || keyInfo.name,
+            color: genNextColor(datasources),
+            funcBody: keyInfo.funcBody,
+            settings: {},
+            _hash: Math.random()
+        }
+        return dataKey;
+    }
+
+    function createDatasourceKeys(keyInfos, type, datasource, datasources) {
+        for (var i=0;i<keyInfos.length;i++) {
+            var keyInfo = keyInfos[i];
+            var dataKey = createKey(keyInfo, type, datasources);
+            datasource.dataKeys.push(dataKey);
+        }
+    }
+
+    function createDatasourceFromSubscription(subscriptionInfo, datasources, device) {
+        var datasource;
+        if (subscriptionInfo.type === types.datasourceType.device) {
+            datasource = {
+                type: subscriptionInfo.type,
+                deviceName: device.name,
+                name: device.name,
+                deviceId: device.id.id,
+                dataKeys: []
+            }
+        } else if (subscriptionInfo.type === types.datasourceType.function) {
+            datasource = {
+                type: subscriptionInfo.type,
+                name: subscriptionInfo.name || types.datasourceType.function,
+                dataKeys: []
+            }
+        }
+        datasources.push(datasource);
+        if (subscriptionInfo.timeseries) {
+            createDatasourceKeys(subscriptionInfo.timeseries, types.dataKeyType.timeseries, datasource, datasources);
+        }
+        if (subscriptionInfo.attributes) {
+            createDatasourceKeys(subscriptionInfo.attributes, types.dataKeyType.attribute, datasource, datasources);
+        }
+        if (subscriptionInfo.functions) {
+            createDatasourceKeys(subscriptionInfo.functions, types.dataKeyType.function, datasource, datasources);
+        }
+    }
+
+    function processSubscriptionsInfo(index, subscriptionsInfo, datasources, deferred) {
+        if (index < subscriptionsInfo.length) {
+            var subscriptionInfo = subscriptionsInfo[index];
+            if (subscriptionInfo.type === types.datasourceType.device) {
+                if (subscriptionInfo.deviceId) {
+                    deviceService.getDevice(subscriptionInfo.deviceId, true, {ignoreLoading: true}).then(
+                        function success(device) {
+                            createDatasourceFromSubscription(subscriptionInfo, datasources, device);
+                            index++;
+                            processSubscriptionsInfo(index, subscriptionsInfo, datasources, deferred);
+                        },
+                        function fail() {
+                            index++;
+                            processSubscriptionsInfo(index, subscriptionsInfo, datasources, deferred);
+                        }
+                    );
+                } else if (subscriptionInfo.deviceName || subscriptionInfo.deviceNamePrefix
+                    || subscriptionInfo.deviceIds) {
+                    var promise;
+                    if (subscriptionInfo.deviceName) {
+                        promise = deviceService.fetchAliasDeviceByNameFilter(subscriptionInfo.deviceName, 1, false, {ignoreLoading: true});
+                    } else if (subscriptionInfo.deviceNamePrefix) {
+                        promise = deviceService.fetchAliasDeviceByNameFilter(subscriptionInfo.deviceNamePrefix, 100, false, {ignoreLoading: true});
+                    } else if (subscriptionInfo.deviceIds) {
+                        promise = deviceService.getDevices(subscriptionInfo.deviceIds, {ignoreLoading: true});
+                    }
+                    promise.then(
+                        function success(devices) {
+                            if (devices && devices.length > 0) {
+                                for (var i = 0; i < devices.length; i++) {
+                                    var device = devices[i];
+                                    createDatasourceFromSubscription(subscriptionInfo, datasources, device);
+                                }
+                            }
+                            index++;
+                            processSubscriptionsInfo(index, subscriptionsInfo, datasources, deferred);
+                        },
+                        function fail() {
+                            index++;
+                            processSubscriptionsInfo(index, subscriptionsInfo, datasources, deferred);
+                        }
+                    )
+                } else {
+                    index++;
+                    processSubscriptionsInfo(index, subscriptionsInfo, datasources, deferred);
+                }
+            } else if (subscriptionInfo.type === types.datasourceType.function) {
+                createDatasourceFromSubscription(subscriptionInfo, datasources);
+                index++;
+                processSubscriptionsInfo(index, subscriptionsInfo, datasources, deferred);
+            }
+        } else {
+            deferred.resolve(datasources);
+        }
+    }
+
+    function createDatasoucesFromSubscriptionsInfo(subscriptionsInfo) {
+        var deferred = $q.defer();
+        var datasources = [];
+        processSubscriptionsInfo(0, subscriptionsInfo, datasources, deferred);
+        return deferred.promise;
+    }
+
+    function isLocalUrl(url) {
+        var parser = document.createElement('a'); //eslint-disable-line
+        parser.href = url;
+        var host = parser.hostname;
+        if (host === "localhost" || host === "127.0.0.1") {
+            return true;
+        } else {
+            return false;
+        }
+    }
+
 }
diff --git a/ui/src/app/components/dashboard.directive.js b/ui/src/app/components/dashboard.directive.js
index 101ff51..131616b 100644
--- a/ui/src/app/components/dashboard.directive.js
+++ b/ui/src/app/components/dashboard.directive.js
@@ -87,8 +87,6 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, t
     var highlightedMode = false;
     var highlightedWidget = null;
     var selectedWidget = null;
-    var mouseDownWidget = -1;
-    var widgetMouseMoved = false;
 
     var gridsterParent = $('#gridster-parent', $element);
     var gridsterElement = angular.element($('#gridster-child', gridsterParent));
@@ -152,9 +150,9 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, t
     vm.resetHighlight = resetHighlight;
 
     vm.onWidgetFullscreenChanged = onWidgetFullscreenChanged;
+
     vm.widgetMouseDown = widgetMouseDown;
-    vm.widgetMouseMove = widgetMouseMove;
-    vm.widgetMouseUp = widgetMouseUp;
+    vm.widgetClicked = widgetClicked;
 
     vm.widgetSizeX = widgetSizeX;
     vm.widgetSizeY = widgetSizeY;
@@ -184,23 +182,23 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, t
 
     vm.dashboardTimewindowApi = {
         onResetTimewindow: function() {
-            if (vm.originalDashboardTimewindow) {
-                vm.dashboardTimewindow = angular.copy(vm.originalDashboardTimewindow);
-                vm.originalDashboardTimewindow = null;
-            }
+            $timeout(function() {
+                if (vm.originalDashboardTimewindow) {
+                    vm.dashboardTimewindow = angular.copy(vm.originalDashboardTimewindow);
+                    vm.originalDashboardTimewindow = null;
+                }
+            }, 0);
         },
         onUpdateTimewindow: function(startTimeMs, endTimeMs) {
             if (!vm.originalDashboardTimewindow) {
                 vm.originalDashboardTimewindow = angular.copy(vm.dashboardTimewindow);
             }
-            vm.dashboardTimewindow = timeService.toHistoryTimewindow(vm.dashboardTimewindow, startTimeMs, endTimeMs);
+            $timeout(function() {
+                vm.dashboardTimewindow = timeService.toHistoryTimewindow(vm.dashboardTimewindow, startTimeMs, endTimeMs);
+            }, 0);
         }
     };
 
-    //$element[0].onmousemove=function(){
-    //    widgetMouseMove();
-   // }
-
     //TODO: widgets visibility
     /*gridsterParent.scroll(function () {
         updateVisibleRect();
@@ -350,7 +348,6 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, t
     }
 
     function loadDashboard() {
-        resetWidgetClick();
         $timeout(function () {
             if (vm.loadWidgets) {
                 var promise = vm.loadWidgets();
@@ -434,42 +431,17 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, t
         return gridsterElement && gridsterElement[0] === gridster.$element[0];
     }
 
-    function resetWidgetClick () {
-        mouseDownWidget = -1;
-        widgetMouseMoved = false;
-    }
-
     function onWidgetFullscreenChanged(expanded, widget) {
         vm.isWidgetExpanded = expanded;
         $scope.$broadcast('onWidgetFullscreenChanged', vm.isWidgetExpanded, widget);
     }
 
     function widgetMouseDown ($event, widget) {
-        mouseDownWidget = widget;
-        widgetMouseMoved = false;
         if (vm.onWidgetMouseDown) {
             vm.onWidgetMouseDown({event: $event, widget: widget});
         }
     }
 
-    function widgetMouseMove () {
-        if (mouseDownWidget) {
-            widgetMouseMoved = true;
-        }
-    }
-
-    function widgetMouseUp ($event, widget) {
-        $timeout(function () {
-            if (!widgetMouseMoved && mouseDownWidget) {
-                if (widget === mouseDownWidget) {
-                    widgetClicked($event, widget);
-                }
-            }
-            mouseDownWidget = null;
-            widgetMouseMoved = false;
-        }, 0);
-    }
-
     function widgetClicked ($event, widget) {
         if ($event) {
             $event.stopPropagation();
@@ -518,7 +490,6 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, t
     }
 
     function editWidget ($event, widget) {
-        resetWidgetClick();
         if ($event) {
             $event.stopPropagation();
         }
@@ -528,7 +499,6 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, t
     }
 
     function exportWidget ($event, widget) {
-        resetWidgetClick();
         if ($event) {
             $event.stopPropagation();
         }
@@ -538,7 +508,6 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, t
     }
 
     function removeWidget($event, widget) {
-        resetWidgetClick();
         if ($event) {
             $event.stopPropagation();
         }
@@ -548,27 +517,21 @@ function DashboardController($scope, $rootScope, $element, $timeout, $mdMedia, t
     }
 
     function highlightWidget(widget, delay) {
-        highlightedMode = true;
-        highlightedWidget = widget;
-        if (vm.gridster) {
-            var item = $('.gridster-item', vm.gridster.$element)[vm.widgets.indexOf(widget)];
-            if (item) {
-                var height = $(item).outerHeight(true);
-                var rectHeight = gridsterParent.height();
-                var offset = (rectHeight - height) / 2;
-                var scrollTop = item.offsetTop;
-                if (offset > 0) {
-                    scrollTop -= offset;
-                }
-                gridsterParent.animate({
-                    scrollTop: scrollTop
-                }, delay);
-            }
+        if (!highlightedMode || highlightedWidget != widget) {
+            highlightedMode = true;
+            highlightedWidget = widget;
+            scrollToWidget(widget, delay);
         }
     }
 
     function selectWidget(widget, delay) {
-        selectedWidget = widget;
+        if (selectedWidget != widget) {
+            selectedWidget = widget;
+            scrollToWidget(widget, delay);
+        }
+    }
+
+    function scrollToWidget(widget, delay) {
         if (vm.gridster) {
             var item = $('.gridster-item', vm.gridster.$element)[vm.widgets.indexOf(widget)];
             if (item) {
diff --git a/ui/src/app/components/dashboard.tpl.html b/ui/src/app/components/dashboard.tpl.html
index 8c42278..3d11e46 100644
--- a/ui/src/app/components/dashboard.tpl.html
+++ b/ui/src/app/components/dashboard.tpl.html
@@ -37,9 +37,7 @@
 										           'tb-not-highlighted': vm.isNotHighlighted(widget),
 										           'md-whiteframe-4dp': vm.dropWidgetShadow(widget)}"
 										tb-mousedown="vm.widgetMouseDown($event, widget)"
-										tb-mousemove="vm.widgetMouseMove($event, widget)"
-										tb-mouseup="vm.widgetMouseUp($event, widget)"
-										ng-click=""
+										ng-click="vm.widgetClicked($event, widget)"
 										tb-contextmenu="vm.openWidgetContextMenu($event, widget, $mdOpenMousepointMenu)"
 									    ng-style="{cursor: 'pointer',
             									   color: vm.widgetColor(widget),
@@ -49,7 +47,7 @@
 									<span ng-show="vm.showWidgetTitle(widget)" ng-style="vm.widgetTitleStyle(widget)" class="md-subhead">{{widget.config.title}}</span>
 									<tb-timewindow aggregation ng-if="vm.hasTimewindow(widget)" ng-model="widget.config.timewindow"></tb-timewindow>
 								</div>
-								<div class="tb-widget-actions" layout="row" layout-align="start center">
+								<div class="tb-widget-actions" layout="row" layout-align="start center" tb-mousedown="$event.stopPropagation()">
 									<md-button id="expand-button"
 											   ng-show="!vm.isEdit && vm.enableWidgetFullscreen(widget)"
 											   aria-label="{{ 'fullscreen.fullscreen' | translate }}"
diff --git a/ui/src/app/components/device-filter.directive.js b/ui/src/app/components/device-filter.directive.js
index ecf12cd..0233c95 100644
--- a/ui/src/app/components/device-filter.directive.js
+++ b/ui/src/app/components/device-filter.directive.js
@@ -41,7 +41,7 @@ function DeviceFilter($compile, $templateCache, $q, deviceService) {
 
             var deferred = $q.defer();
 
-            deviceService.getTenantDevices(pageLink).then(function success(result) {
+            deviceService.getTenantDevices(pageLink, false).then(function success(result) {
                 deferred.resolve(result.data);
             }, function fail() {
                 deferred.reject();
diff --git a/ui/src/app/components/grid.directive.js b/ui/src/app/components/grid.directive.js
index 7f610dc..5664400 100644
--- a/ui/src/app/components/grid.directive.js
+++ b/ui/src/app/components/grid.directive.js
@@ -26,6 +26,7 @@ import gridTemplate from './grid.tpl.html';
 
 export default angular.module('thingsboard.directives.grid', [thingsboardScopeElement, thingsboardDetailsSidenav])
     .directive('tbGrid', Grid)
+    .controller('ItemCardController', ItemCardController)
     .directive('tbGridCardContent', GridCardContent)
     .filter('range', RangeFilter)
     .name;
@@ -44,14 +45,52 @@ function RangeFilter() {
 }
 
 /*@ngInject*/
-function GridCardContent($compile) {
+function ItemCardController() {
+
+    var vm = this; //eslint-disable-line
+
+}
+
+/*@ngInject*/
+function GridCardContent($compile, $controller) {
     var linker = function(scope, element) {
+
+        var controllerInstance = null;
+
         scope.$watch('itemTemplate',
-            function(value) {
-                element.html(value);
-                $compile(element.contents())(scope);
+            function() {
+                initContent();
+            }
+        );
+        scope.$watch('itemController',
+            function() {
+                initContent();
             }
         );
+        scope.$watch('parentCtl',
+            function() {
+                controllerInstance.parentCtl = scope.parentCtl;
+            }
+        );
+        scope.$watch('item',
+            function() {
+                controllerInstance.item = scope.item;
+            }
+        );
+
+        function initContent() {
+            if (scope.itemTemplate && scope.itemController && !controllerInstance) {
+                element.html(scope.itemTemplate);
+                var locals = {};
+                angular.extend(locals, {$scope: scope, $element: element});
+                var controller = $controller(scope.itemController, locals, true, 'vm');
+                controller.instance = controller();
+                controllerInstance = controller.instance;
+                controllerInstance.item = scope.item;
+                controllerInstance.parentCtl = scope.parentCtl;
+                $compile(element.contents())(scope);
+            }
+        }
     };
 
     return {
@@ -61,6 +100,7 @@ function GridCardContent($compile) {
             parentCtl: "=parentCtl",
             gridCtl: "=gridCtl",
             itemTemplate: "=itemTemplate",
+            itemController: "=itemController",
             item: "=item"
         }
     };
@@ -171,26 +211,31 @@ function GridController($scope, $state, $mdDialog, $document, $q, $timeout, $tra
                     vm.items.pending = true;
                     promise.then(
                         function success(items) {
-                            vm.items.data = vm.items.data.concat(items.data);
-                            var startIndex = vm.items.data.length - items.data.length;
-                            var endIndex = vm.items.data.length;
-                            for (var i = startIndex; i < endIndex; i++) {
-                                var item = vm.items.data[i];
-                                item.index = i;
-                                var row = Math.floor(i / vm.columns);
-                                var itemRow = vm.items.rowData[row];
-                                if (!itemRow) {
-                                    itemRow = [];
-                                    vm.items.rowData.push(itemRow);
+                            if (vm.items.reloadPending) {
+                                vm.items.pending = false;
+                                reload();
+                            } else {
+                                vm.items.data = vm.items.data.concat(items.data);
+                                var startIndex = vm.items.data.length - items.data.length;
+                                var endIndex = vm.items.data.length;
+                                for (var i = startIndex; i < endIndex; i++) {
+                                    var item = vm.items.data[i];
+                                    item.index = i;
+                                    var row = Math.floor(i / vm.columns);
+                                    var itemRow = vm.items.rowData[row];
+                                    if (!itemRow) {
+                                        itemRow = [];
+                                        vm.items.rowData.push(itemRow);
+                                    }
+                                    itemRow.push(item);
                                 }
-                                itemRow.push(item);
-                            }
-                            vm.items.nextPageLink = items.nextPageLink;
-                            vm.items.hasNext = items.hasNext;
-                            if (vm.items.hasNext) {
-                                vm.items.nextPageLink.limit = pageSize;
+                                vm.items.nextPageLink = items.nextPageLink;
+                                vm.items.hasNext = items.hasNext;
+                                if (vm.items.hasNext) {
+                                    vm.items.nextPageLink.limit = pageSize;
+                                }
+                                vm.items.pending = false;
                             }
-                            vm.items.pending = false;
                         },
                         function fail() {
                             vm.items.hasNext = false;
@@ -291,6 +336,11 @@ function GridController($scope, $state, $mdDialog, $document, $q, $timeout, $tra
         } else if (vm.config.itemCardTemplateUrl) {
             vm.itemCardTemplate = $templateCache.get(vm.config.itemCardTemplateUrl);
         }
+        if (vm.config.itemCardController) {
+            vm.itemCardController =  vm.config.itemCardController;
+        } else {
+            vm.itemCardController = 'ItemCardController';
+        }
 
         vm.parentCtl = vm.config.parentCtl || vm;
 
@@ -380,26 +430,35 @@ function GridController($scope, $state, $mdDialog, $document, $q, $timeout, $tra
     }
 
     $scope.$on('searchTextUpdated', function () {
-        vm.items = {
-            data: [],
-            rowData: [],
-            nextPageLink: {
-                limit: pageSize,
-                textSearch: $scope.searchConfig.searchText
-            },
-            selections: {},
-            selectedCount: 0,
-            hasNext: true,
-            pending: false
-        };
-        vm.detailsConfig.isDetailsOpen = false;
-        vm.itemRows.getItemAtIndex(pageSize);
+        reload();
     });
 
     vm.onGridInited(vm);
 
     vm.itemRows.getItemAtIndex(pageSize);
 
+    function reload() {
+        if (vm.items && vm.items.pending) {
+            vm.items.reloadPending = true;
+        } else {
+            vm.items = {
+                data: [],
+                rowData: [],
+                nextPageLink: {
+                    limit: pageSize,
+                    textSearch: $scope.searchConfig.searchText
+                },
+                selections: {},
+                selectedCount: 0,
+                hasNext: true,
+                pending: false
+            };
+            vm.detailsConfig.isDetailsOpen = false;
+            vm.items.reloadPending = false;
+            vm.itemRows.getItemAtIndex(pageSize);
+        }
+    }
+
     function refreshList() {
         $state.go($state.current, vm.refreshParamsFunc(), {reload: true});
     }
diff --git a/ui/src/app/components/grid.tpl.html b/ui/src/app/components/grid.tpl.html
index 05eb350..c29c2e5 100644
--- a/ui/src/app/components/grid.tpl.html
+++ b/ui/src/app/components/grid.tpl.html
@@ -43,13 +43,13 @@
                                 </md-card-title>
                             </section>
                             <md-card-content flex>
-                                <tb-grid-card-content grid-ctl="vm" parent-ctl="vm.parentCtl" item-template="vm.itemCardTemplate" item="rowItem[n]"></tb-grid-card-content>
+                                <tb-grid-card-content grid-ctl="vm" parent-ctl="vm.parentCtl" item-controller="vm.itemCardController" item-template="vm.itemCardTemplate" item="rowItem[n]"></tb-grid-card-content>
                             </md-card-content>
                             <md-card-actions layout="row" layout-align="end end">
                                 <md-button ng-if="action.isEnabled(rowItem[n])" ng-disabled="loading" class="md-icon-button md-primary" ng-repeat="action in vm.actionsList"
                                            ng-click="action.onAction($event, rowItem[n])" aria-label="{{ action.name() }}">
                                     <md-tooltip md-direction="top">
-                                        {{ action.details() }}
+                                        {{ action.details( rowItem[n] ) }}
                                     </md-tooltip>
                                     <ng-md-icon icon="{{action.icon}}"></ng-md-icon>
                                 </md-button>
@@ -62,7 +62,7 @@
     </div>
     <tb-details-sidenav
             header-title="{{vm.getItemTitleFunc(vm.operatingItem())}}"
-            header-subtitle="{{vm.itemDetailsText()}}"
+            header-subtitle="{{vm.itemDetailsText(vm.operatingItem())}}"
             is-read-only="vm.isDetailsReadOnly(vm.operatingItem())"
             is-open="vm.detailsConfig.isDetailsOpen"
             is-edit="vm.detailsConfig.isDetailsEditMode"
diff --git a/ui/src/app/components/legend.directive.js b/ui/src/app/components/legend.directive.js
index fba58e4..196629c 100644
--- a/ui/src/app/components/legend.directive.js
+++ b/ui/src/app/components/legend.directive.js
@@ -44,15 +44,8 @@ function Legend($compile, $templateCache, types) {
         scope.isHorizontal = scope.legendConfig.position === types.position.bottom.value ||
             scope.legendConfig.position === types.position.top.value;
 
-        scope.$on('legendDataUpdated', function (event, apply) {
-            if (apply) {
-                scope.$digest();
-            }
-        });
-
         scope.toggleHideData = function(index) {
             scope.legendData.data[index].hidden = !scope.legendData.data[index].hidden;
-            scope.$emit('legendDataHiddenChanged', index);
         }
 
         $compile(element.contents())(scope);
diff --git a/ui/src/app/components/socialshare-panel.directive.js b/ui/src/app/components/socialshare-panel.directive.js
new file mode 100644
index 0000000..891d913
--- /dev/null
+++ b/ui/src/app/components/socialshare-panel.directive.js
@@ -0,0 +1,58 @@
+/*
+ * Copyright © 2016-2017 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.
+ */
+
+/* eslint-disable import/no-unresolved, import/default */
+
+import socialsharePanelTemplate from './socialshare-panel.tpl.html';
+
+/* eslint-enable import/no-unresolved, import/default */
+
+
+export default angular.module('thingsboard.directives.socialsharePanel', [])
+    .directive('tbSocialSharePanel', SocialsharePanel)
+    .name;
+
+/*@ngInject*/
+function SocialsharePanel() {
+    return {
+        restrict: "E",
+        scope: true,
+        bindToController: {
+            shareTitle: '@',
+            shareText: '@',
+            shareLink: '@',
+            shareHashTags: '@'
+        },
+        controller: SocialsharePanelController,
+        controllerAs: 'vm',
+        templateUrl: socialsharePanelTemplate
+    };
+}
+
+/*@ngInject*/
+function SocialsharePanelController(utils) {
+
+    let vm = this;
+
+    vm.isShareLinkLocal = function() {
+        if (vm.shareLink && vm.shareLink.length > 0) {
+            return utils.isLocalUrl(vm.shareLink);
+        } else {
+            return true;
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/ui/src/app/components/socialshare-panel.tpl.html b/ui/src/app/components/socialshare-panel.tpl.html
new file mode 100644
index 0000000..7b9560d
--- /dev/null
+++ b/ui/src/app/components/socialshare-panel.tpl.html
@@ -0,0 +1,62 @@
+<!--
+
+    Copyright © 2016-2017 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.
+
+-->
+
+<div layout="row" ng-show="!vm.isShareLinkLocal()">
+    <md-button class="md-icon-button md-raised md-primary"
+               socialshare
+               socialshare-provider="facebook"
+               socialshare-title="{{ vm.shareTitle }}"
+               socialshare-text="{{ vm.shareText }}"
+               socialshare-url="{{ vm.shareLink }}">
+        <ng-md-icon icon="facebook" aria-label="Facebook"></ng-md-icon>
+        <md-tooltip md-direction="top">
+            {{ 'action.share-via' | translate:{provider:'Facebook'} }}
+        </md-tooltip>
+    </md-button>
+    <md-button class="md-icon-button md-raised md-primary"
+               socialshare
+               socialshare-provider="twitter"
+               socialshare-text="{{ vm.shareTitle }}"
+               socialshare-hashtags="{{ vm.shareHashTags }}"
+               socialshare-url="{{ vm.shareLink }}">
+        <ng-md-icon icon="twitter" aria-label="Twitter"></ng-md-icon>
+        <md-tooltip md-direction="top">
+            {{ 'action.share-via' | translate:{provider:'Twitter'} }}
+        </md-tooltip>
+    </md-button>
+    <md-button class="md-icon-button md-raised md-primary"
+               socialshare
+               socialshare-provider="linkedin"
+               socialshare-text="{{ vm.shareTitle }}"
+               socialshare-url="{{ vm.shareLink }}">
+        <ng-md-icon icon="linkedin" aria-label="Linkedin"></ng-md-icon>
+        <md-tooltip md-direction="top">
+            {{ 'action.share-via' | translate:{provider:'Linkedin'} }}
+        </md-tooltip>
+    </md-button>
+    <md-button class="md-icon-button md-raised md-primary"
+               socialshare
+               socialshare-provider="reddit"
+               socialshare-text="{{ vm.shareTitle }}"
+               socialshare-url="{{ vm.shareLink }}">
+        <md-icon md-svg-icon="mdi:reddit" aria-label="Reddit"></md-icon>
+        <md-tooltip md-direction="top">
+            {{ 'action.share-via' | translate:{provider:'Reddit'} }}
+        </md-tooltip>
+    </md-button>
+</div>
\ No newline at end of file
diff --git a/ui/src/app/components/widget.controller.js b/ui/src/app/components/widget.controller.js
index 6307a87..7c7caa9 100644
--- a/ui/src/app/components/widget.controller.js
+++ b/ui/src/app/components/widget.controller.js
@@ -15,6 +15,7 @@
  */
 import $ from 'jquery';
 import 'javascript-detect-element-resize/detect-element-resize';
+import Subscription from '../api/subscription';
 
 /* eslint-disable angular/angularelement */
 
@@ -34,19 +35,11 @@ export default function WidgetController($scope, $timeout, $window, $element, $q
     $scope.rpcErrorText = null;
     $scope.rpcEnabled = false;
     $scope.executingRpcRequest = false;
-    $scope.executingPromises = [];
 
     var gridsterItemInited = false;
 
-    var datasourceListeners = [];
-    var targetDeviceAliasId = null;
-    var targetDeviceId = null;
-    var originalTimewindow = null;
-    var subscriptionTimewindow = null;
     var cafs = {};
 
-    var varsRegex = /\$\{([^\}]*)\}/g;
-
     /*
      *   data = array of datasourceData
      *   datasourceData = {
@@ -54,8 +47,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q
      *   			dataKey,     { name, config }
      *   			data = array of [time, value]
      *   }
-     *
-     *
      */
 
     var widgetContext = {
@@ -71,22 +62,70 @@ export default function WidgetController($scope, $timeout, $window, $element, $q
         settings: widget.config.settings,
         units: widget.config.units || '',
         decimals: angular.isDefined(widget.config.decimals) ? widget.config.decimals : 2,
-        datasources: angular.copy(widget.config.datasources),
-        data: [],
-        hiddenData: [],
-        timeWindow: {
-            stDiff: stDiff
-        },
+        subscriptions: {},
+        defaultSubscription: null,
         timewindowFunctions: {
-            onUpdateTimewindow: onUpdateTimewindow,
-            onResetTimewindow: onResetTimewindow
+            onUpdateTimewindow: function(startTimeMs, endTimeMs) {
+                if (widgetContext.defaultSubscription) {
+                    widgetContext.defaultSubscription.onUpdateTimewindow(startTimeMs, endTimeMs);
+                }
+            },
+            onResetTimewindow: function() {
+                if (widgetContext.defaultSubscription) {
+                    widgetContext.defaultSubscription.onResetTimewindow();
+                }
+            }
+        },
+        subscriptionApi: {
+            createSubscription: function(options, subscribe) {
+                return createSubscription(options, subscribe);
+            },
+
+
+    //      type: "timeseries" or "latest" or "rpc"
+    /*      devicesSubscriptionInfo = [
+            {
+                deviceId:   ""
+                deviceName: ""
+                timeseries: [{ name: "", label: "" }, ..]
+                attributes: [{ name: "", label: "" }, ..]
+            }
+    ..
+    ]*/
+
+    //      options = {
+    //        timeWindowConfig,
+    //        useDashboardTimewindow,
+    //        legendConfig,
+    //        decimals,
+    //        units,
+    //        callbacks [ onDataUpdated(subscription, apply) ]
+    //      }
+    //
+
+            createSubscriptionFromInfo: function (type, subscriptionsInfo, options, useDefaultComponents, subscribe) {
+                return createSubscriptionFromInfo(type, subscriptionsInfo, options, useDefaultComponents, subscribe);
+            },
+            removeSubscription: function(id) {
+                var subscription = widgetContext.subscriptions[id];
+                if (subscription) {
+                    subscription.destroy();
+                    delete widgetContext.subscriptions[id];
+                }
+            }
         },
         controlApi: {
             sendOneWayCommand: function(method, params, timeout) {
-                return sendCommand(true, method, params, timeout);
+                if (widgetContext.defaultSubscription) {
+                    return widgetContext.defaultSubscription.sendOneWayCommand(method, params, timeout);
+                }
+                return null;
             },
             sendTwoWayCommand: function(method, params, timeout) {
-                return sendCommand(false, method, params, timeout);
+                if (widgetContext.defaultSubscription) {
+                    return widgetContext.defaultSubscription.sendTwoWayCommand(method, params, timeout);
+                }
+                return null;
             }
         },
         utils: {
@@ -94,7 +133,27 @@ export default function WidgetController($scope, $timeout, $window, $element, $q
         }
     };
 
+    var subscriptionContext = {
+        $scope: $scope,
+        $q: $q,
+        $filter: $filter,
+        $timeout: $timeout,
+        tbRaf: tbRaf,
+        timeService: timeService,
+        deviceService: deviceService,
+        datasourceService: datasourceService,
+        utils: utils,
+        widgetUtils: widgetContext.utils,
+        dashboardTimewindowApi: dashboardTimewindowApi,
+        types: types,
+        stDiff: stDiff,
+        aliasesInfo: aliasesInfo
+    };
+
     var widgetTypeInstance;
+
+    vm.useCustomDatasources = false;
+
     try {
         widgetTypeInstance = new widgetType(widgetContext);
     } catch (e) {
@@ -119,19 +178,14 @@ export default function WidgetController($scope, $timeout, $window, $element, $q
     if (!widgetTypeInstance.onDestroy) {
         widgetTypeInstance.onDestroy = function() {};
     }
-
-    //var bounds = {top: 0, left: 0, bottom: 0, right: 0};
-    //TODO: widgets visibility
-    /*var visible = false;*/
-
-    $scope.clearRpcError = function() {
-        $scope.rpcRejection = null;
-        $scope.rpcErrorText = null;
+    if (widgetTypeInstance.useCustomDatasources) {
+        vm.useCustomDatasources = widgetTypeInstance.useCustomDatasources();
     }
 
-    vm.gridsterItemInitialized = gridsterItemInitialized;
-
     //TODO: widgets visibility
+
+    //var bounds = {top: 0, left: 0, bottom: 0, right: 0};
+    /*var visible = false;*/
     /*vm.visibleRectChanged = visibleRectChanged;
 
     function visibleRectChanged(newVisibleRect) {
@@ -139,23 +193,216 @@ export default function WidgetController($scope, $timeout, $window, $element, $q
         updateVisibility();
     }*/
 
+    $scope.clearRpcError = function() {
+        if (widgetContext.defaultSubscription) {
+            widgetContext.defaultSubscription.clearRpcError();
+        }
+    }
+
+    vm.gridsterItemInitialized = gridsterItemInitialized;
+
     initialize();
 
-    function handleWidgetException(e) {
-        $log.error(e);
-        $scope.widgetErrorData = utils.processWidgetException(e);
+
+    /*
+            options = {
+                 type,
+                 targetDeviceAliasIds,  // RPC
+                 targetDeviceIds,       // RPC
+                 datasources,
+                 timeWindowConfig,
+                 useDashboardTimewindow,
+                 legendConfig,
+                 decimals,
+                 units,
+                 callbacks
+            }
+     */
+
+    function createSubscriptionFromInfo(type, subscriptionsInfo, options, useDefaultComponents, subscribe) {
+        var deferred = $q.defer();
+        options.type = type;
+
+        if (useDefaultComponents) {
+            defaultComponentsOptions(options);
+        } else {
+            if (!options.timeWindowConfig) {
+                options.useDashboardTimewindow = true;
+            }
+        }
+
+        utils.createDatasoucesFromSubscriptionsInfo(subscriptionsInfo).then(
+            function (datasources) {
+                options.datasources = datasources;
+                var subscription = createSubscription(options, subscribe);
+                if (useDefaultComponents) {
+                    defaultSubscriptionOptions(subscription, options);
+                }
+                deferred.resolve(subscription);
+            }
+        );
+        return deferred.promise;
+    }
+
+    function createSubscription(options, subscribe) {
+        options.dashboardTimewindow = dashboardTimewindow;
+        var subscription =
+            new Subscription(subscriptionContext, options);
+        widgetContext.subscriptions[subscription.id] = subscription;
+        if (subscribe) {
+            subscription.subscribe();
+        }
+        return subscription;
     }
 
-    function notifyDataLoaded() {
-        if ($scope.loadingData === true) {
+    function defaultComponentsOptions(options) {
+        options.useDashboardTimewindow = angular.isDefined(widget.config.useDashboardTimewindow)
+            ? widget.config.useDashboardTimewindow : true;
+
+        options.timeWindowConfig = options.useDashboardTimewindow ? dashboardTimewindow : widget.config.timewindow;
+        options.legendConfig = null;
+
+        if ($scope.displayLegend) {
+            options.legendConfig = $scope.legendConfig;
+        }
+        options.decimals = widgetContext.decimals;
+        options.units = widgetContext.units;
+
+        options.callbacks = {
+            onDataUpdated: function() {
+                widgetTypeInstance.onDataUpdated();
+            },
+            onDataUpdateError: function(subscription, e) {
+                handleWidgetException(e);
+            },
+            dataLoading: function(subscription) {
+                if ($scope.loadingData !== subscription.loadingData) {
+                    $scope.loadingData = subscription.loadingData;
+                }
+            },
+            legendDataUpdated: function(subscription, apply) {
+                if (apply) {
+                    $scope.$digest();
+                }
+            },
+            timeWindowUpdated: function(subscription, timeWindowConfig) {
+                widget.config.timewindow = timeWindowConfig;
+                $scope.$apply();
+            }
+        }
+    }
+
+    function defaultSubscriptionOptions(subscription, options) {
+        if (!options.useDashboardTimewindow) {
+            $scope.$watch(function () {
+                return widget.config.timewindow;
+            }, function (newTimewindow, prevTimewindow) {
+                if (!angular.equals(newTimewindow, prevTimewindow)) {
+                    subscription.updateTimewindowConfig(widget.config.timewindow);
+                }
+            });
+        }
+        if ($scope.displayLegend) {
+            $scope.legendData = subscription.legendData;
+        }
+    }
+
+    function createDefaultSubscription() {
+        var subscription;
+        var options;
+        if (widget.type !== types.widgetType.rpc.value && widget.type !== types.widgetType.static.value) {
+            options = {
+                type: widget.type,
+                datasources: angular.copy(widget.config.datasources)
+            };
+            defaultComponentsOptions(options);
+
+            subscription = createSubscription(options);
+
+            defaultSubscriptionOptions(subscription, options);
+
+            // backward compatibility
+
+            widgetContext.datasources = subscription.datasources;
+            widgetContext.data = subscription.data;
+            widgetContext.hiddenData = subscription.hiddenData;
+            widgetContext.timeWindow = subscription.timeWindow;
+
+        } else if (widget.type === types.widgetType.rpc.value) {
+            $scope.loadingData = false;
+            options = {
+                type: widget.type,
+                targetDeviceAliasIds: widget.config.targetDeviceAliasIds
+            }
+            options.callbacks = {
+                rpcStateChanged: function(subscription) {
+                    $scope.rpcEnabled = subscription.rpcEnabled;
+                    $scope.executingRpcRequest = subscription.executingRpcRequest;
+                },
+                onRpcSuccess: function(subscription) {
+                    $scope.executingRpcRequest = subscription.executingRpcRequest;
+                },
+                onRpcFailed: function(subscription) {
+                    $scope.executingRpcRequest = subscription.executingRpcRequest;
+                    $scope.rpcErrorText = subscription.rpcErrorText;
+                    $scope.rpcRejection = subscription.rpcRejection;
+                },
+                onRpcErrorCleared: function() {
+                    $scope.rpcErrorText = null;
+                    $scope.rpcRejection = null;
+                }
+            }
+            subscription = createSubscription(options);
+        } else if (widget.type === types.widgetType.static.value) {
             $scope.loadingData = false;
         }
+        if (subscription) {
+            widgetContext.defaultSubscription = subscription;
+        }
     }
 
-    function notifyDataLoading() {
-        if ($scope.loadingData === false) {
-            $scope.loadingData = true;
+
+    function initialize() {
+
+        if (!vm.useCustomDatasources) {
+            createDefaultSubscription();
+        } else {
+            $scope.loadingData = false;
         }
+
+        $scope.$on('toggleDashboardEditMode', function (event, isEdit) {
+            onEditModeChanged(isEdit);
+        });
+
+        addResizeListener(widgetContext.$containerParent[0], onResize); // eslint-disable-line no-undef
+
+        $scope.$watch(function () {
+            return widget.row + ',' + widget.col + ',' + widget.config.mobileOrder;
+        }, function () {
+            //updateBounds();
+            $scope.$emit("widgetPositionChanged", widget);
+        });
+
+        $scope.$on('gridster-item-resized', function (event, item) {
+            if (!widgetContext.isMobile) {
+                widget.sizeX = item.sizeX;
+                widget.sizeY = item.sizeY;
+            }
+        });
+
+        $scope.$on('mobileModeChanged', function (event, newIsMobile) {
+            onMobileModeChanged(newIsMobile);
+        });
+
+        $scope.$on("$destroy", function () {
+            removeResizeListener(widgetContext.$containerParent[0], onResize); // eslint-disable-line no-undef
+            onDestroy();
+        });
+    }
+
+    function handleWidgetException(e) {
+        $log.error(e);
+        $scope.widgetErrorData = utils.processWidgetException(e);
     }
 
     function onInit() {
@@ -166,42 +413,12 @@ export default function WidgetController($scope, $timeout, $window, $element, $q
             } catch (e) {
                 handleWidgetException(e);
             }
-            if (widgetContext.dataUpdatePending) {
-                widgetContext.dataUpdatePending = false;
-                onDataUpdated();
+            if (!vm.useCustomDatasources && widgetContext.defaultSubscription) {
+                widgetContext.defaultSubscription.subscribe();
             }
         }
     }
 
-    function updateTimewindow() {
-        widgetContext.timeWindow.interval = subscriptionTimewindow.aggregation.interval || 1000;
-        if (subscriptionTimewindow.realtimeWindowMs) {
-            widgetContext.timeWindow.maxTime = (new Date).getTime() + widgetContext.timeWindow.stDiff;
-            widgetContext.timeWindow.minTime = widgetContext.timeWindow.maxTime - subscriptionTimewindow.realtimeWindowMs;
-        } else if (subscriptionTimewindow.fixedWindow) {
-            widgetContext.timeWindow.maxTime = subscriptionTimewindow.fixedWindow.endTimeMs;
-            widgetContext.timeWindow.minTime = subscriptionTimewindow.fixedWindow.startTimeMs;
-        }
-    }
-
-    function onDataUpdated() {
-        if (widgetContext.inited) {
-            if (cafs['dataUpdate']) {
-                cafs['dataUpdate']();
-                cafs['dataUpdate'] = null;
-            }
-            cafs['dataUpdate'] = tbRaf(function() {
-                    try {
-                        widgetTypeInstance.onDataUpdated();
-                    } catch (e) {
-                        handleWidgetException(e);
-                    }
-                });
-        } else {
-            widgetContext.dataUpdatePending = true;
-        }
-    }
-
     function checkSize() {
         var width = widgetContext.$containerParent.width();
         var height = widgetContext.$containerParent.height();
@@ -289,11 +506,35 @@ export default function WidgetController($scope, $timeout, $window, $element, $q
         }
     }
 
+    function isNumeric(val) {
+        return (val - parseFloat( val ) + 1) >= 0;
+    }
+
+    function formatValue(value, dec, units) {
+        if (angular.isDefined(value) &&
+            value !== null && isNumeric(value)) {
+            var formatted = value;
+            if (angular.isDefined(dec)) {
+                formatted = formatted.toFixed(dec);
+            }
+            formatted = (formatted * 1).toString();
+            if (angular.isDefined(units) && units.length > 0) {
+                formatted += ' ' + units;
+            }
+            return formatted;
+        } else {
+            return '';
+        }
+    }
+
     function onDestroy() {
-        unsubscribe();
+        for (var id in widgetContext.subscriptions) {
+            var subscription = widgetContext.subscriptions[id];
+            subscription.destroy();
+        }
+        widgetContext.subscriptions = [];
         if (widgetContext.inited) {
             widgetContext.inited = false;
-            widgetContext.dataUpdatePending = false;
             for (var cafId in cafs) {
                 if (cafs[cafId]) {
                     cafs[cafId]();
@@ -308,243 +549,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q
         }
     }
 
-    function onRestart() {
-        onDestroy();
-        onInit();
-    }
-
-/*    scope.legendData = {
-        keys: [],
-        data: []
-
-        key: {
-             label: '',
-             color: ''
-             dataIndex: 0
-        }
-        data: {
-             min: null,
-             max: null,
-             avg: null,
-             total: null
-        }
-    };*/
-
-
-    function initialize() {
-
-        $scope.caulculateLegendData = $scope.displayLegend &&
-            widget.type === types.widgetType.timeseries.value &&
-            ($scope.legendConfig.showMin === true ||
-             $scope.legendConfig.showMax === true ||
-             $scope.legendConfig.showAvg === true ||
-             $scope.legendConfig.showTotal === true);
-
-        if (widget.type !== types.widgetType.rpc.value && widget.type !== types.widgetType.static.value) {
-            for (var i = 0; i < widgetContext.datasources.length; i++) {
-                var datasource = widgetContext.datasources[i];
-                for (var a = 0; a < datasource.dataKeys.length; a++) {
-                    var dataKey = datasource.dataKeys[a];
-                    dataKey.pattern = angular.copy(dataKey.label);
-                    var datasourceData = {
-                        datasource: datasource,
-                        dataKey: dataKey,
-                        data: []
-                    };
-                    widgetContext.data.push(datasourceData);
-                    widgetContext.hiddenData.push({data: []});
-                    if ($scope.displayLegend) {
-                        var legendKey = {
-                            dataKey: dataKey,
-                            dataIndex: Number(i) + Number(a)
-                        };
-                        $scope.legendData.keys.push(legendKey);
-                        var legendKeyData = {
-                            min: null,
-                            max: null,
-                            avg: null,
-                            total: null,
-                            hidden: false
-                        };
-                        $scope.legendData.data.push(legendKeyData);
-                    }
-                }
-            }
-            if ($scope.displayLegend) {
-                $scope.legendData.keys = $filter('orderBy')($scope.legendData.keys, '+label');
-                $scope.$on('legendDataHiddenChanged', function (event, index) {
-                    event.stopPropagation();
-                    var hidden = $scope.legendData.data[index].hidden;
-                    if (hidden) {
-                        widgetContext.hiddenData[index].data = widgetContext.data[index].data;
-                        widgetContext.data[index].data = [];
-                    } else {
-                        widgetContext.data[index].data = widgetContext.hiddenData[index].data;
-                        widgetContext.hiddenData[index].data = [];
-                    }
-                    onDataUpdated();
-                });
-            }
-        } else if (widget.type === types.widgetType.rpc.value) {
-            if (widget.config.targetDeviceAliasIds && widget.config.targetDeviceAliasIds.length > 0) {
-                targetDeviceAliasId = widget.config.targetDeviceAliasIds[0];
-                if (aliasesInfo.deviceAliases[targetDeviceAliasId]) {
-                    targetDeviceId = aliasesInfo.deviceAliases[targetDeviceAliasId].deviceId;
-                }
-            }
-            if (targetDeviceId) {
-                $scope.rpcEnabled = true;
-            } else {
-                $scope.rpcEnabled = $scope.widgetEditMode ? true : false;
-            }
-        }
-
-        $scope.$on('toggleDashboardEditMode', function (event, isEdit) {
-            onEditModeChanged(isEdit);
-        });
-
-        addResizeListener(widgetContext.$containerParent[0], onResize); // eslint-disable-line no-undef
-
-        $scope.$watch(function () {
-            return widget.row + ',' + widget.col + ',' + widget.config.mobileOrder;
-        }, function () {
-            //updateBounds();
-            $scope.$emit("widgetPositionChanged", widget);
-        });
-
-        $scope.$on('gridster-item-resized', function (event, item) {
-            if (!widgetContext.isMobile) {
-                widget.sizeX = item.sizeX;
-                widget.sizeY = item.sizeY;
-            }
-        });
-
-        $scope.$on('mobileModeChanged', function (event, newIsMobile) {
-            onMobileModeChanged(newIsMobile);
-        });
-
-        $scope.$on('deviceAliasListChanged', function (event, newAliasesInfo) {
-            aliasesInfo = newAliasesInfo;
-            if (widget.type === types.widgetType.rpc.value) {
-                if (targetDeviceAliasId) {
-                    var deviceId = null;
-                    if (aliasesInfo.deviceAliases[targetDeviceAliasId]) {
-                        deviceId = aliasesInfo.deviceAliases[targetDeviceAliasId].deviceId;
-                    }
-                    if (!angular.equals(deviceId, targetDeviceId)) {
-                        targetDeviceId = deviceId;
-                        if (targetDeviceId) {
-                            $scope.rpcEnabled = true;
-                        } else {
-                            $scope.rpcEnabled = $scope.widgetEditMode ? true : false;
-                        }
-                        onRestart();
-                    }
-                }
-            } else {
-                checkSubscriptions();
-            }
-        });
-
-        $scope.$on("$destroy", function () {
-            removeResizeListener(widgetContext.$containerParent[0], onResize); // eslint-disable-line no-undef
-            onDestroy();
-        });
-
-        if (widget.type === types.widgetType.timeseries.value) {
-            widgetContext.useDashboardTimewindow = angular.isDefined(widget.config.useDashboardTimewindow)
-                    ? widget.config.useDashboardTimewindow : true;
-            if (widgetContext.useDashboardTimewindow) {
-                $scope.$on('dashboardTimewindowChanged', function (event, newDashboardTimewindow) {
-                    if (!angular.equals(dashboardTimewindow, newDashboardTimewindow)) {
-                        dashboardTimewindow = newDashboardTimewindow;
-                        unsubscribe();
-                        subscribe();
-                    }
-                });
-            } else {
-                $scope.$watch(function () {
-                    return widgetContext.useDashboardTimewindow ? dashboardTimewindow : widget.config.timewindow;
-                }, function (newTimewindow, prevTimewindow) {
-                    if (!angular.equals(newTimewindow, prevTimewindow)) {
-                        unsubscribe();
-                        subscribe();
-                    }
-                });
-            }
-        }
-        subscribe();
-    }
-
-    function sendCommand(oneWayElseTwoWay, method, params, timeout) {
-        if (!$scope.rpcEnabled) {
-            return $q.reject();
-        }
-
-        if ($scope.rpcRejection && $scope.rpcRejection.status !== 408) {
-            $scope.rpcRejection = null;
-            $scope.rpcErrorText = null;
-        }
-
-        var requestBody = {
-            method: method,
-            params: params
-        };
-
-        if (timeout && timeout > 0) {
-            requestBody.timeout = timeout;
-        }
-
-        var deferred = $q.defer();
-        $scope.executingRpcRequest = true;
-        if ($scope.widgetEditMode) {
-            $timeout(function() {
-                $scope.executingRpcRequest = false;
-                if (oneWayElseTwoWay) {
-                    deferred.resolve();
-                } else {
-                    deferred.resolve(requestBody);
-                }
-            }, 500);
-        } else {
-            $scope.executingPromises.push(deferred.promise);
-            var targetSendFunction = oneWayElseTwoWay ? deviceService.sendOneWayRpcCommand : deviceService.sendTwoWayRpcCommand;
-            targetSendFunction(targetDeviceId, requestBody).then(
-                function success(responseBody) {
-                    $scope.rpcRejection = null;
-                    $scope.rpcErrorText = null;
-                    var index = $scope.executingPromises.indexOf(deferred.promise);
-                    if (index >= 0) {
-                        $scope.executingPromises.splice( index, 1 );
-                    }
-                    $scope.executingRpcRequest = $scope.executingPromises.length > 0;
-                    deferred.resolve(responseBody);
-                },
-                function fail(rejection) {
-                    var index = $scope.executingPromises.indexOf(deferred.promise);
-                    if (index >= 0) {
-                        $scope.executingPromises.splice( index, 1 );
-                    }
-                    $scope.executingRpcRequest = $scope.executingPromises.length > 0;
-                    if (!$scope.executingRpcRequest || rejection.status === 408) {
-                        $scope.rpcRejection = rejection;
-                        if (rejection.status === 408) {
-                            $scope.rpcErrorText = 'Device is offline.';
-                        } else {
-                            $scope.rpcErrorText =  'Error : ' + rejection.status + ' - ' + rejection.statusText;
-                            if (rejection.data && rejection.data.length > 0) {
-                                $scope.rpcErrorText += '</br>';
-                                $scope.rpcErrorText += rejection.data;
-                            }
-                        }
-                    }
-                    deferred.reject(rejection);
-                }
-            );
-        }
-        return deferred.promise;
-    }
-
     //TODO: widgets visibility
     /*function updateVisibility(forceRedraw) {
         if (visibleRect) {
@@ -583,285 +587,6 @@ export default function WidgetController($scope, $timeout, $window, $element, $q
         onRedraw();
     }*/
 
-    function onResetTimewindow() {
-        if (widgetContext.useDashboardTimewindow) {
-            dashboardTimewindowApi.onResetTimewindow();
-        } else {
-            if (originalTimewindow) {
-                widget.config.timewindow = angular.copy(originalTimewindow);
-                originalTimewindow = null;
-            }
-        }
-    }
-
-    function onUpdateTimewindow(startTimeMs, endTimeMs) {
-        if (widgetContext.useDashboardTimewindow) {
-            dashboardTimewindowApi.onUpdateTimewindow(startTimeMs, endTimeMs);
-        } else {
-            if (!originalTimewindow) {
-                originalTimewindow = angular.copy(widget.config.timewindow);
-            }
-            widget.config.timewindow = timeService.toHistoryTimewindow(widget.config.timewindow, startTimeMs, endTimeMs);
-        }
-    }
-
-    function dataUpdated(sourceData, datasourceIndex, dataKeyIndex, apply) {
-        notifyDataLoaded();
-        var update = true;
-        var currentData;
-        if ($scope.displayLegend && $scope.legendData.data[datasourceIndex + dataKeyIndex].hidden) {
-            currentData = widgetContext.hiddenData[datasourceIndex + dataKeyIndex];
-        } else {
-            currentData = widgetContext.data[datasourceIndex + dataKeyIndex];
-        }
-        if (widget.type === types.widgetType.latest.value) {
-            var prevData = currentData.data;
-            if (prevData && prevData[0] && prevData[0].length > 1 && sourceData.data.length > 0) {
-                var prevValue = prevData[0][1];
-                if (prevValue === sourceData.data[0][1]) {
-                    update = false;
-                }
-            }
-        }
-        if (update) {
-            if (subscriptionTimewindow && subscriptionTimewindow.realtimeWindowMs) {
-                updateTimewindow();
-            }
-            currentData.data = sourceData.data;
-            onDataUpdated();
-            if ($scope.caulculateLegendData) {
-                updateLegend(datasourceIndex + dataKeyIndex, sourceData.data, apply);
-            }
-        }
-        if (apply) {
-            $scope.$digest();
-        }
-    }
-
-    function updateLegend(dataIndex, data, apply) {
-        var legendKeyData = $scope.legendData.data[dataIndex];
-        if ($scope.legendConfig.showMin) {
-            legendKeyData.min = formatValue(calculateMin(data), widgetContext.decimals, widgetContext.units);
-        }
-        if ($scope.legendConfig.showMax) {
-            legendKeyData.max = formatValue(calculateMax(data), widgetContext.decimals, widgetContext.units);
-        }
-        if ($scope.legendConfig.showAvg) {
-            legendKeyData.avg = formatValue(calculateAvg(data), widgetContext.decimals, widgetContext.units);
-        }
-        if ($scope.legendConfig.showTotal) {
-            legendKeyData.total = formatValue(calculateTotal(data), widgetContext.decimals, widgetContext.units);
-        }
-        $scope.$broadcast('legendDataUpdated', apply !== false);
-    }
-
-    function isNumeric(val) {
-        return (val - parseFloat( val ) + 1) >= 0;
-    }
-
-    function formatValue(value, dec, units) {
-        if (angular.isDefined(value) &&
-            value !== null && isNumeric(value)) {
-            var formatted = value;
-            if (angular.isDefined(dec)) {
-                formatted = formatted.toFixed(dec);
-            }
-            formatted = (formatted * 1).toString();
-            if (angular.isDefined(units) && units.length > 0) {
-                formatted += ' ' + units;
-            }
-            return formatted;
-        } else {
-            return '';
-        }
-    }
-
-    function calculateMin(data) {
-        if (data.length > 0) {
-            var result = Number(data[0][1]);
-            for (var i=1;i<data.length;i++) {
-                result = Math.min(result, Number(data[i][1]));
-            }
-            return result;
-        } else {
-            return null;
-        }
-    }
-
-    function calculateMax(data) {
-        if (data.length > 0) {
-            var result = Number(data[0][1]);
-            for (var i=1;i<data.length;i++) {
-                result = Math.max(result, Number(data[i][1]));
-            }
-            return result;
-        } else {
-            return null;
-        }
-    }
-
-    function calculateAvg(data) {
-        if (data.length > 0) {
-            return calculateTotal(data)/data.length;
-        } else {
-            return null;
-        }
-    }
-
-    function calculateTotal(data) {
-        if (data.length > 0) {
-            var result = 0;
-            for (var i = 0; i < data.length; i++) {
-                result += Number(data[i][1]);
-            }
-            return result;
-        } else {
-            return null;
-        }
-    }
-
-    function checkSubscriptions() {
-        if (widget.type !== types.widgetType.rpc.value) {
-            var subscriptionsChanged = false;
-            for (var i = 0; i < datasourceListeners.length; i++) {
-                var listener = datasourceListeners[i];
-                var deviceId = null;
-                var aliasName = null;
-                if (listener.datasource.type === types.datasourceType.device) {
-                    if (aliasesInfo.deviceAliases[listener.datasource.deviceAliasId]) {
-                        deviceId = aliasesInfo.deviceAliases[listener.datasource.deviceAliasId].deviceId;
-                        aliasName = aliasesInfo.deviceAliases[listener.datasource.deviceAliasId].alias;
-                    }
-                    if (!angular.equals(deviceId, listener.deviceId) ||
-                        !angular.equals(aliasName, listener.datasource.name)) {
-                        subscriptionsChanged = true;
-                        break;
-                    }
-                }
-            }
-            if (subscriptionsChanged) {
-                unsubscribe();
-                subscribe();
-            }
-        }
-    }
-
-    function unsubscribe() {
-        if (widget.type !== types.widgetType.rpc.value) {
-            for (var i = 0; i < datasourceListeners.length; i++) {
-                var listener = datasourceListeners[i];
-                datasourceService.unsubscribeFromDatasource(listener);
-            }
-            datasourceListeners = [];
-        }
-    }
-
-    function updateRealtimeSubscription(_subscriptionTimewindow) {
-        if (_subscriptionTimewindow) {
-            subscriptionTimewindow = _subscriptionTimewindow;
-        } else {
-            subscriptionTimewindow =
-                timeService.createSubscriptionTimewindow(
-                    widgetContext.useDashboardTimewindow ? dashboardTimewindow : widget.config.timewindow,
-                    widgetContext.timeWindow.stDiff);
-        }
-        updateTimewindow();
-        return subscriptionTimewindow;
-    }
-
-    function hasTimewindow() {
-        if (widgetContext.useDashboardTimewindow) {
-            return angular.isDefined(dashboardTimewindow);
-        } else {
-            return angular.isDefined(widget.config.timewindow);
-        }
-    }
-
-    function updateDataKeyLabel(dataKey, deviceName, aliasName) {
-        var pattern = dataKey.pattern;
-        var label = dataKey.pattern;
-        var match = varsRegex.exec(pattern);
-        while (match !== null) {
-            var variable = match[0];
-            var variableName = match[1];
-            if (variableName === 'deviceName') {
-                label = label.split(variable).join(deviceName);
-            } else if (variableName === 'aliasName') {
-                label = label.split(variable).join(aliasName);
-            }
-            match = varsRegex.exec(pattern);
-        }
-        dataKey.label = label;
-    }
-
-    function subscribe() {
-        if (widget.type !== types.widgetType.rpc.value && widget.type !== types.widgetType.static.value) {
-            notifyDataLoading();
-            if (widget.type === types.widgetType.timeseries.value &&
-                hasTimewindow()) {
-                updateRealtimeSubscription();
-                if (subscriptionTimewindow.fixedWindow) {
-                    onDataUpdated();
-                }
-            }
-            var index = 0;
-            for (var i = 0; i < widgetContext.datasources.length; i++) {
-                var datasource = widgetContext.datasources[i];
-                if (angular.isFunction(datasource))
-                    continue;
-                var deviceId = null;
-                if (datasource.type === types.datasourceType.device && datasource.deviceAliasId) {
-                    if (aliasesInfo.deviceAliases[datasource.deviceAliasId]) {
-                        deviceId = aliasesInfo.deviceAliases[datasource.deviceAliasId].deviceId;
-                        datasource.name = aliasesInfo.deviceAliases[datasource.deviceAliasId].alias;
-                        var aliasName = aliasesInfo.deviceAliases[datasource.deviceAliasId].alias;
-                        var deviceName = '';
-                        var devicesInfo = aliasesInfo.deviceAliasesInfo[datasource.deviceAliasId];
-                        for (var d=0;d<devicesInfo.length;d++) {
-                            if (devicesInfo[d].id === deviceId) {
-                                deviceName = devicesInfo[d].name;
-                                break;
-                            }
-                        }
-                        for (var dk = 0; dk < datasource.dataKeys.length; dk++) {
-                            updateDataKeyLabel(datasource.dataKeys[dk], deviceName, aliasName);
-                        }
-                    }
-                } else {
-                    datasource.name = types.datasourceType.function;
-                }
-                var listener = {
-                    widget: widget,
-                    subscriptionTimewindow: subscriptionTimewindow,
-                    datasource: datasource,
-                    deviceId: deviceId,
-                    dataUpdated: function (data, datasourceIndex, dataKeyIndex, apply) {
-                        dataUpdated(data, datasourceIndex, dataKeyIndex, apply);
-                    },
-                    updateRealtimeSubscription: function() {
-                        this.subscriptionTimewindow = updateRealtimeSubscription();
-                        return this.subscriptionTimewindow;
-                    },
-                    setRealtimeSubscription: function(subscriptionTimewindow) {
-                        updateRealtimeSubscription(angular.copy(subscriptionTimewindow));
-                    },
-                    datasourceIndex: index
-                };
-
-                for (var a = 0; a < datasource.dataKeys.length; a++) {
-                    widgetContext.data[index + a].data = [];
-                }
-
-                index += datasource.dataKeys.length;
-
-                datasourceListeners.push(listener);
-                datasourceService.subscribeToDatasource(listener);
-            }
-        } else {
-            notifyDataLoaded();
-        }
-    }
-
 }
 
 /* eslint-enable angular/angularelement */
\ No newline at end of file
diff --git a/ui/src/app/components/widget-config.directive.js b/ui/src/app/components/widget-config.directive.js
index 61a5d14..bcffa72 100644
--- a/ui/src/app/components/widget-config.directive.js
+++ b/ui/src/app/components/widget-config.directive.js
@@ -76,6 +76,10 @@ function WidgetConfig($compile, $templateCache, $rootScope, $timeout, types, uti
             scope.forceExpandDatasources = false;
         }
 
+        if (angular.isUndefined(scope.isDataEnabled)) {
+            scope.isDataEnabled = true;
+        }
+
         scope.currentSettingsSchema = {};
         scope.currentSettings = angular.copy(scope.emptySettingsSchema);
 
@@ -108,7 +112,8 @@ function WidgetConfig($compile, $templateCache, $rootScope, $timeout, types, uti
                 scope.showLegend = angular.isDefined(ngModelCtrl.$viewValue.showLegend) ?
                     ngModelCtrl.$viewValue.showLegend : scope.widgetType === types.widgetType.timeseries.value;
                 scope.legendConfig = ngModelCtrl.$viewValue.legendConfig;
-                if (scope.widgetType !== types.widgetType.rpc.value && scope.widgetType !== types.widgetType.static.value) {
+                if (scope.widgetType !== types.widgetType.rpc.value && scope.widgetType !== types.widgetType.static.value
+                    && scope.isDataEnabled) {
                     if (scope.datasources) {
                         scope.datasources.splice(0, scope.datasources.length);
                     } else {
@@ -119,7 +124,7 @@ function WidgetConfig($compile, $templateCache, $rootScope, $timeout, types, uti
                             scope.datasources.push({value: ngModelCtrl.$viewValue.datasources[i]});
                         }
                     }
-                } else if (scope.widgetType === types.widgetType.rpc.value) {
+                } else if (scope.widgetType === types.widgetType.rpc.value && scope.isDataEnabled) {
                     if (ngModelCtrl.$viewValue.targetDeviceAliasIds && ngModelCtrl.$viewValue.targetDeviceAliasIds.length > 0) {
                         var aliasId = ngModelCtrl.$viewValue.targetDeviceAliasIds[0];
                         if (scope.deviceAliases[aliasId]) {
@@ -159,10 +164,10 @@ function WidgetConfig($compile, $templateCache, $rootScope, $timeout, types, uti
             if (ngModelCtrl.$viewValue) {
                 var value = ngModelCtrl.$viewValue;
                 var valid;
-                if (scope.widgetType === types.widgetType.rpc.value) {
+                if (scope.widgetType === types.widgetType.rpc.value && scope.isDataEnabled) {
                     valid = value && value.targetDeviceAliasIds && value.targetDeviceAliasIds.length > 0;
                     ngModelCtrl.$setValidity('targetDeviceAliasIds', valid);
-                } else if (scope.widgetType !== types.widgetType.static.value) {
+                } else if (scope.widgetType !== types.widgetType.static.value && scope.isDataEnabled) {
                     valid = value && value.datasources && value.datasources.length > 0;
                     ngModelCtrl.$setValidity('datasources', valid);
                 }
@@ -228,7 +233,7 @@ function WidgetConfig($compile, $templateCache, $rootScope, $timeout, types, uti
 
         scope.$watch('datasources', function () {
             if (ngModelCtrl.$viewValue && scope.widgetType !== types.widgetType.rpc.value
-                && scope.widgetType !== types.widgetType.static.value) {
+                && scope.widgetType !== types.widgetType.static.value && scope.isDataEnabled) {
                 var value = ngModelCtrl.$viewValue;
                 if (value.datasources) {
                     value.datasources.splice(0, value.datasources.length);
@@ -246,7 +251,7 @@ function WidgetConfig($compile, $templateCache, $rootScope, $timeout, types, uti
         }, true);
 
         scope.$watch('targetDeviceAlias.value', function () {
-            if (ngModelCtrl.$viewValue && scope.widgetType === types.widgetType.rpc.value) {
+            if (ngModelCtrl.$viewValue && scope.widgetType === types.widgetType.rpc.value && scope.isDataEnabled) {
                 var value = ngModelCtrl.$viewValue;
                 if (scope.targetDeviceAlias.value) {
                     value.targetDeviceAliasIds = [scope.targetDeviceAlias.value.id];
@@ -359,6 +364,7 @@ function WidgetConfig($compile, $templateCache, $rootScope, $timeout, types, uti
         require: "^ngModel",
         scope: {
             forceExpandDatasources: '=?',
+            isDataEnabled: '=?',
             widgetType: '=',
             widgetSettingsSchema: '=',
             datakeySettingsSchema: '=',
diff --git a/ui/src/app/components/widget-config.tpl.html b/ui/src/app/components/widget-config.tpl.html
index 66b440f..e7c03e3 100644
--- a/ui/src/app/components/widget-config.tpl.html
+++ b/ui/src/app/components/widget-config.tpl.html
@@ -31,7 +31,7 @@
                 </section>
             </div>
             <v-accordion id="datasources-accordion" control="datasourcesAccordion" class="vAccordion--default"
-                         ng-show="widgetType !== types.widgetType.rpc.value && widgetType !== types.widgetType.static.value">
+                         ng-show="widgetType !== types.widgetType.rpc.value && widgetType !== types.widgetType.static.value && isDataEnabled">
                 <v-pane id="datasources-pane" expanded="true">
                     <v-pane-header>
                         {{ 'widget-config.datasources' | translate }}
@@ -96,7 +96,7 @@
                 </v-pane>
             </v-accordion>
             <v-accordion id="target-devices-accordion" control="targetDevicesAccordion" class="vAccordion--default"
-                         ng-show="widgetType === types.widgetType.rpc.value">
+                         ng-show="widgetType === types.widgetType.rpc.value && isDataEnabled">
                 <v-pane id="target-devices-pane" expanded="true">
                     <v-pane-header>
                         {{ 'widget-config.target-device' | translate }}
diff --git a/ui/src/app/customer/customer.controller.js b/ui/src/app/customer/customer.controller.js
index 5738660..4182588 100644
--- a/ui/src/app/customer/customer.controller.js
+++ b/ui/src/app/customer/customer.controller.js
@@ -30,14 +30,23 @@ export default function CustomerController(customerService, $state, $stateParams
             },
             name: function() { return $translate.instant('user.users') },
             details: function() { return $translate.instant('customer.manage-customer-users') },
-            icon: "account_circle"
+            icon: "account_circle",
+            isEnabled: function(customer) {
+                return customer && (!customer.additionalInfo || !customer.additionalInfo.isPublic);
+            }
         },
         {
             onAction: function ($event, item) {
                 openCustomerDevices($event, item);
             },
             name: function() { return $translate.instant('device.devices') },
-            details: function() { return $translate.instant('customer.manage-customer-devices') },
+            details: function(customer) {
+                if (customer && customer.additionalInfo && customer.additionalInfo.isPublic) {
+                    return $translate.instant('customer.manage-public-devices')
+                } else {
+                    return $translate.instant('customer.manage-customer-devices')
+                }
+            },
             icon: "devices_other"
         },
         {
@@ -45,7 +54,13 @@ export default function CustomerController(customerService, $state, $stateParams
                 openCustomerDashboards($event, item);
             },
             name: function() { return $translate.instant('dashboard.dashboards') },
-            details: function() { return $translate.instant('customer.manage-customer-dashboards') },
+            details: function(customer) {
+                if (customer && customer.additionalInfo && customer.additionalInfo.isPublic) {
+                    return $translate.instant('customer.manage-public-dashboards')
+                } else {
+                    return $translate.instant('customer.manage-customer-dashboards')
+                }
+            },
             icon: "dashboard"
         },
         {
@@ -54,7 +69,10 @@ export default function CustomerController(customerService, $state, $stateParams
             },
             name: function() { return $translate.instant('action.delete') },
             details: function() { return $translate.instant('customer.delete') },
-            icon: "delete"
+            icon: "delete",
+            isEnabled: function(customer) {
+                return customer && (!customer.additionalInfo || !customer.additionalInfo.isPublic);
+            }
         }
     ];
 
@@ -86,7 +104,19 @@ export default function CustomerController(customerService, $state, $stateParams
 
         addItemText: function() { return $translate.instant('customer.add-customer-text') },
         noItemsText: function() { return $translate.instant('customer.no-customers-text') },
-        itemDetailsText: function() { return $translate.instant('customer.customer-details') }
+        itemDetailsText: function(customer) {
+            if (customer && (!customer.additionalInfo || !customer.additionalInfo.isPublic)) {
+                return $translate.instant('customer.customer-details')
+            } else {
+                return '';
+            }
+        },
+        isSelectionEnabled: function (customer) {
+            return customer && (!customer.additionalInfo || !customer.additionalInfo.isPublic);
+        },
+        isDetailsReadOnly: function (customer) {
+            return customer && customer.additionalInfo && customer.additionalInfo.isPublic;
+        }
     };
 
     if (angular.isDefined($stateParams.items) && $stateParams.items !== null) {
diff --git a/ui/src/app/customer/customer.directive.js b/ui/src/app/customer/customer.directive.js
index f23711a..4a4e09c 100644
--- a/ui/src/app/customer/customer.directive.js
+++ b/ui/src/app/customer/customer.directive.js
@@ -24,7 +24,21 @@ export default function CustomerDirective($compile, $templateCache) {
     var linker = function (scope, element) {
         var template = $templateCache.get(customerFieldsetTemplate);
         element.html(template);
+
+        scope.isPublic = false;
+
+        scope.$watch('customer', function(newVal) {
+            if (newVal) {
+                if (scope.customer.additionalInfo) {
+                    scope.isPublic = scope.customer.additionalInfo.isPublic;
+                } else {
+                    scope.isPublic = false;
+                }
+            }
+        });
+
         $compile(element.contents())(scope);
+
     }
     return {
         restrict: "E",
diff --git a/ui/src/app/customer/customer-card.tpl.html b/ui/src/app/customer/customer-card.tpl.html
index 8c96313..e15ed9d 100644
--- a/ui/src/app/customer/customer-card.tpl.html
+++ b/ui/src/app/customer/customer-card.tpl.html
@@ -15,4 +15,4 @@
     limitations under the License.
 
 -->
-<div class="tb-uppercase">{{ item | contactShort }}</div>
\ No newline at end of file
+<div ng-show="item && (!item.additionalInfo || !item.additionalInfo.isPublic)" class="tb-uppercase">{{ item | contactShort }}</div>
\ No newline at end of file
diff --git a/ui/src/app/customer/customer-fieldset.tpl.html b/ui/src/app/customer/customer-fieldset.tpl.html
index 62ef6b3..a216571 100644
--- a/ui/src/app/customer/customer-fieldset.tpl.html
+++ b/ui/src/app/customer/customer-fieldset.tpl.html
@@ -15,13 +15,13 @@
     limitations under the License.
 
 -->
-<md-button ng-click="onManageUsers({event: $event})" ng-show="!isEdit" class="md-raised md-primary">{{ 'customer.manage-users' | translate }}</md-button>
+<md-button ng-click="onManageUsers({event: $event})" ng-show="!isEdit && !isPublic" class="md-raised md-primary">{{ 'customer.manage-users' | translate }}</md-button>
 <md-button ng-click="onManageDevices({event: $event})" ng-show="!isEdit" class="md-raised md-primary">{{ 'customer.manage-devices' | translate }}</md-button>
 <md-button ng-click="onManageDashboards({event: $event})" ng-show="!isEdit" class="md-raised md-primary">{{ 'customer.manage-dashboards' | translate }}</md-button>
-<md-button ng-click="onDeleteCustomer({event: $event})" ng-show="!isEdit" class="md-raised md-primary">{{ 'customer.delete' | translate }}</md-button>
+<md-button ng-click="onDeleteCustomer({event: $event})" ng-show="!isEdit && !isPublic" class="md-raised md-primary">{{ 'customer.delete' | translate }}</md-button>
 
 <md-content class="md-padding" layout="column">
-	<fieldset ng-disabled="loading || !isEdit">
+	<fieldset ng-show="!isPublic" ng-disabled="loading || !isEdit">
 		<md-input-container class="md-block">
 			<label translate>customer.title</label>
 			<input required name="title" ng-model="customer.title">	
diff --git a/ui/src/app/dashboard/dashboard.controller.js b/ui/src/app/dashboard/dashboard.controller.js
index fbb3e37..9121f6a 100644
--- a/ui/src/app/dashboard/dashboard.controller.js
+++ b/ui/src/app/dashboard/dashboard.controller.js
@@ -48,6 +48,8 @@ export default function DashboardController(types, widgetService, userService,
 
     vm.isToolbarOpened = false;
 
+    vm.thingsboardVersion = THINGSBOARD_VERSION; //eslint-disable-line
+
     vm.currentDashboardId = $stateParams.dashboardId;
     if ($stateParams.customerId) {
         vm.currentCustomerId = $stateParams.customerId;
@@ -86,6 +88,7 @@ export default function DashboardController(types, widgetService, userService,
     vm.exportDashboard = exportDashboard;
     vm.exportWidget = exportWidget;
     vm.importWidget = importWidget;
+    vm.isPublicUser = isPublicUser;
     vm.isTenantAdmin = isTenantAdmin;
     vm.isSystemAdmin = isSystemAdmin;
     vm.loadDashboard = loadDashboard;
@@ -104,6 +107,9 @@ export default function DashboardController(types, widgetService, userService,
     vm.onRevertWidgetEdit = onRevertWidgetEdit;
     vm.helpLinkIdForWidgetType = helpLinkIdForWidgetType;
     vm.displayTitle = displayTitle;
+    vm.displayExport = displayExport;
+    vm.displayDashboardTimewindow = displayDashboardTimewindow;
+    vm.displayDevicesSelect = displayDevicesSelect;
 
     vm.widgetsBundle;
 
@@ -273,6 +279,10 @@ export default function DashboardController(types, widgetService, userService,
         vm.dashboardInitComplete = true;
     }
 
+    function isPublicUser() {
+        return vm.user.isPublic === true;
+    }
+
     function isTenantAdmin() {
         return vm.user.authority === 'TENANT_ADMIN';
     }
@@ -560,6 +570,33 @@ export default function DashboardController(types, widgetService, userService,
         }
     }
 
+    function displayExport() {
+        if (vm.dashboard && vm.dashboard.configuration.gridSettings &&
+            angular.isDefined(vm.dashboard.configuration.gridSettings.showDashboardExport)) {
+            return vm.dashboard.configuration.gridSettings.showDashboardExport;
+        } else {
+            return true;
+        }
+    }
+
+    function displayDashboardTimewindow() {
+        if (vm.dashboard && vm.dashboard.configuration.gridSettings &&
+            angular.isDefined(vm.dashboard.configuration.gridSettings.showDashboardTimewindow)) {
+            return vm.dashboard.configuration.gridSettings.showDashboardTimewindow;
+        } else {
+            return true;
+        }
+    }
+
+    function displayDevicesSelect() {
+        if (vm.dashboard && vm.dashboard.configuration.gridSettings &&
+            angular.isDefined(vm.dashboard.configuration.gridSettings.showDevicesSelect)) {
+            return vm.dashboard.configuration.gridSettings.showDevicesSelect;
+        } else {
+            return true;
+        }
+    }
+
     function onRevertWidgetEdit(widgetForm) {
         if (widgetForm.$dirty) {
             widgetForm.$setPristine();
@@ -617,22 +654,8 @@ export default function DashboardController(types, widgetService, userService,
                     sizeY: widgetTypeInfo.sizeY,
                     config: config
                 };
-                $mdDialog.show({
-                    controller: 'AddWidgetController',
-                    controllerAs: 'vm',
-                    templateUrl: addWidgetTemplate,
-                    locals: {dashboard: vm.dashboard, aliasesInfo: vm.aliasesInfo, widget: newWidget, widgetInfo: widgetTypeInfo},
-                    parent: angular.element($document[0].body),
-                    fullscreen: true,
-                    skipHide: true,
-                    targetEvent: event,
-                    onComplete: function () {
-                        var w = angular.element($window);
-                        w.triggerHandler('resize');
-                    }
-                }).then(function (result) {
-                    var widget = result.widget;
-                    vm.aliasesInfo = result.aliasesInfo;
+
+                function addWidget(widget) {
                     var columns = 24;
                     if (vm.dashboard.configuration.gridSettings && vm.dashboard.configuration.gridSettings.columns) {
                         columns = vm.dashboard.configuration.gridSettings.columns;
@@ -643,9 +666,37 @@ export default function DashboardController(types, widgetService, userService,
                         widget.sizeY *= ratio;
                     }
                     vm.widgets.push(widget);
-                }, function (rejection) {
-                    vm.aliasesInfo = rejection.aliasesInfo;
-                });
+                }
+
+                if (widgetTypeInfo.useCustomDatasources) {
+                    addWidget(newWidget);
+                } else {
+                    $mdDialog.show({
+                        controller: 'AddWidgetController',
+                        controllerAs: 'vm',
+                        templateUrl: addWidgetTemplate,
+                        locals: {
+                            dashboard: vm.dashboard,
+                            aliasesInfo: vm.aliasesInfo,
+                            widget: newWidget,
+                            widgetInfo: widgetTypeInfo
+                        },
+                        parent: angular.element($document[0].body),
+                        fullscreen: true,
+                        skipHide: true,
+                        targetEvent: event,
+                        onComplete: function () {
+                            var w = angular.element($window);
+                            w.triggerHandler('resize');
+                        }
+                    }).then(function (result) {
+                        var widget = result.widget;
+                        vm.aliasesInfo = result.aliasesInfo;
+                        addWidget(widget);
+                    }, function (rejection) {
+                        vm.aliasesInfo = rejection.aliasesInfo;
+                    });
+                }
             }
         );
     }
diff --git a/ui/src/app/dashboard/dashboard.directive.js b/ui/src/app/dashboard/dashboard.directive.js
index 2e9f55a..b2f3117 100644
--- a/ui/src/app/dashboard/dashboard.directive.js
+++ b/ui/src/app/dashboard/dashboard.directive.js
@@ -20,30 +20,44 @@ import dashboardFieldsetTemplate from './dashboard-fieldset.tpl.html';
 /* eslint-enable import/no-unresolved, import/default */
 
 /*@ngInject*/
-export default function DashboardDirective($compile, $templateCache, types, customerService) {
+export default function DashboardDirective($compile, $templateCache, $translate, types, toast, customerService, dashboardService) {
     var linker = function (scope, element) {
         var template = $templateCache.get(dashboardFieldsetTemplate);
         element.html(template);
 
         scope.isAssignedToCustomer = false;
+        scope.isPublic = false;
         scope.assignedCustomer = null;
+        scope.publicLink = null;
 
         scope.$watch('dashboard', function(newVal) {
             if (newVal) {
                 if (scope.dashboard.customerId && scope.dashboard.customerId.id !== types.id.nullUid) {
                     scope.isAssignedToCustomer = true;
-                    customerService.getCustomer(scope.dashboard.customerId.id).then(
+                    customerService.getShortCustomerInfo(scope.dashboard.customerId.id).then(
                         function success(customer) {
                             scope.assignedCustomer = customer;
+                            scope.isPublic = customer.isPublic;
+                            if (scope.isPublic) {
+                                scope.publicLink = dashboardService.getPublicDashboardLink(scope.dashboard);
+                            } else {
+                                scope.publicLink = null;
+                            }
                         }
                     );
                 } else {
                     scope.isAssignedToCustomer = false;
+                    scope.isPublic = false;
+                    scope.publicLink = null;
                     scope.assignedCustomer = null;
                 }
             }
         });
 
+        scope.onPublicLinkCopied = function() {
+            toast.showSuccess($translate.instant('dashboard.public-link-copied-message'), 750, angular.element(element).parent().parent(), 'bottom left');
+        };
+
         $compile(element.contents())(scope);
     }
     return {
@@ -55,6 +69,7 @@ export default function DashboardDirective($compile, $templateCache, types, cust
             dashboardScope: '=',
             theForm: '=',
             onAssignToCustomer: '&',
+            onMakePublic: '&',
             onUnassignFromCustomer: '&',
             onExportDashboard: '&',
             onDeleteDashboard: '&'
diff --git a/ui/src/app/dashboard/dashboard.routes.js b/ui/src/app/dashboard/dashboard.routes.js
index 2c400ea..e9fe1f2 100644
--- a/ui/src/app/dashboard/dashboard.routes.js
+++ b/ui/src/app/dashboard/dashboard.routes.js
@@ -62,7 +62,7 @@ export default function DashboardRoutes($stateProvider) {
                 pageTitle: 'customer.dashboards'
             },
             ncyBreadcrumb: {
-                label: '{"icon": "dashboard", "label": "customer.dashboards"}'
+                label: '{"icon": "dashboard", "label": "{{ vm.customerDashboardsTitle }}", "translate": "false"}'
             }
         })
         .state('home.dashboards.dashboard', {
diff --git a/ui/src/app/dashboard/dashboard.tpl.html b/ui/src/app/dashboard/dashboard.tpl.html
index ad9e52b..9276e0f 100644
--- a/ui/src/app/dashboard/dashboard.tpl.html
+++ b/ui/src/app/dashboard/dashboard.tpl.html
@@ -47,18 +47,23 @@
                                aria-label="{{ 'fullscreen.fullscreen' | translate }}"
                                class="md-icon-button">
                     </md-button>
-                    <tb-user-menu ng-show="forceFullscreen" display-user-info="true">
+                    <tb-user-menu ng-if="!vm.isPublicUser() && forceFullscreen" display-user-info="true">
                     </tb-user-menu>
-                    <md-button aria-label="{{ 'action.export' | translate }}" class="md-icon-button"
+                    <md-button ng-show="vm.isEdit || vm.displayExport()"
+                               aria-label="{{ 'action.export' | translate }}" class="md-icon-button"
                                ng-click="vm.exportDashboard($event)">
                         <md-tooltip md-direction="bottom">
                             {{ 'dashboard.export' | translate }}
                         </md-tooltip>
                         <md-icon aria-label="{{ 'action.export' | translate }}" class="material-icons">file_download</md-icon>
                     </md-button>
-                    <tb-timewindow is-toolbar direction="left" tooltip-direction="bottom" aggregation ng-model="vm.dashboardConfiguration.timewindow">
+                    <tb-timewindow ng-show="vm.isEdit || vm.displayDashboardTimewindow()"
+                                   is-toolbar
+                                   direction="left"
+                                   tooltip-direction="bottom" aggregation
+                                   ng-model="vm.dashboardConfiguration.timewindow">
                     </tb-timewindow>
-                    <tb-aliases-device-select ng-show="!vm.isEdit"
+                    <tb-aliases-device-select ng-show="!vm.isEdit && vm.displayDevicesSelect()"
                                               tooltip-direction="bottom"
                                               ng-model="vm.aliasesInfo.deviceAliases"
                                               device-aliases-info="vm.aliasesInfo.deviceAliasesInfo">
@@ -304,6 +309,6 @@
         </section>
     </section>
     <section class="tb-powered-by-footer" ng-style="{'color': vm.dashboard.configuration.gridSettings.titleColor}">
-        <span>Powered by <a href="https://thingsboard.io" target="_blank">Thingsboard</a></span>
+        <span>Powered by <a href="https://thingsboard.io" target="_blank">Thingsboard v.{{ vm.thingsboardVersion }}</a></span>
     </section>
 </md-content>
diff --git a/ui/src/app/dashboard/dashboard-card.tpl.html b/ui/src/app/dashboard/dashboard-card.tpl.html
index 9cb8a4b..6367867 100644
--- a/ui/src/app/dashboard/dashboard-card.tpl.html
+++ b/ui/src/app/dashboard/dashboard-card.tpl.html
@@ -15,6 +15,6 @@
     limitations under the License.
 
 -->
-<div class="tb-small" ng-if="item &&
-            item.customerId.id != parentCtl.types.id.nullUid &&
-            parentCtl.dashboardsScope === 'tenant'" translate>dashboard.assignedToCustomer</div>
+<div class="tb-small" ng-show="vm.isAssignedToCustomer()">{{'dashboard.assignedToCustomer' | translate}} '{{vm.item.assignedCustomer.title}}'</div>
+<div class="tb-small" ng-show="vm.isPublic()">{{'dashboard.public' | translate}}</div>
+
diff --git a/ui/src/app/dashboard/dashboard-fieldset.tpl.html b/ui/src/app/dashboard/dashboard-fieldset.tpl.html
index 6325e17..954d881 100644
--- a/ui/src/app/dashboard/dashboard-fieldset.tpl.html
+++ b/ui/src/app/dashboard/dashboard-fieldset.tpl.html
@@ -15,25 +15,50 @@
     limitations under the License.
 
 -->
+<md-button ng-click="onExportDashboard({event: $event})"
+		   ng-show="!isEdit && dashboardScope === 'tenant'"
+		   class="md-raised md-primary">{{ 'dashboard.export' | translate }}</md-button>
+<md-button ng-click="onMakePublic({event: $event})"
+		   ng-show="!isEdit && dashboardScope === 'tenant' && !isAssignedToCustomer && !isPublic"
+		   class="md-raised md-primary">{{ 'dashboard.make-public' | translate }}</md-button>
 <md-button ng-click="onAssignToCustomer({event: $event})"
 		   ng-show="!isEdit && dashboardScope === 'tenant' && !isAssignedToCustomer"
 		   class="md-raised md-primary">{{ 'dashboard.assign-to-customer' | translate }}</md-button>
-<md-button ng-click="onUnassignFromCustomer({event: $event})"
+<md-button ng-click="onUnassignFromCustomer({event: $event, isPublic: isPublic})"
 		   ng-show="!isEdit && (dashboardScope === 'customer' || dashboardScope === 'tenant') && isAssignedToCustomer"
-		   class="md-raised md-primary">{{ 'dashboard.unassign-from-customer' | translate }}</md-button>
-<md-button ng-click="onExportDashboard({event: $event})"
-		   ng-show="!isEdit && dashboardScope === 'tenant'"
-		   class="md-raised md-primary">{{ 'dashboard.export' | translate }}</md-button>
+		   class="md-raised md-primary">{{ isPublic ? 'dashboard.make-private' : 'dashboard.unassign-from-customer' | translate }}</md-button>
 <md-button ng-click="onDeleteDashboard({event: $event})"
 		   ng-show="!isEdit && dashboardScope === 'tenant'"
 		   class="md-raised md-primary">{{ 'dashboard.delete' | translate }}</md-button>
-
 <md-content class="md-padding" layout="column">
 	<md-input-container class="md-block"
-						ng-show="isAssignedToCustomer && dashboardScope === 'tenant'">
+						ng-show="!isEdit && isAssignedToCustomer && !isPublic && dashboardScope === 'tenant'">
 		<label translate>dashboard.assignedToCustomer</label>
 		<input ng-model="assignedCustomer.title" disabled>
 	</md-input-container>
+	<div layout="column" ng-show="!isEdit && isPublic && (dashboardScope === 'customer' || dashboardScope === 'tenant')">
+		<tb-social-share-panel style="padding-bottom: 10px;"
+							   share-title="{{ 'dashboard.socialshare-title' | translate:{dashboardTitle: dashboard.title} }}"
+							   share-text="{{ 'dashboard.socialshare-text' | translate:{dashboardTitle: dashboard.title} }}"
+							   share-link="{{ publicLink }}"
+							   share-hash-tags="thingsboard, iot">
+		</tb-social-share-panel>
+		<div layout="row">
+			<md-input-container class="md-block" flex>
+				<label translate>dashboard.public-link</label>
+				<input ng-model="publicLink" disabled>
+			</md-input-container>
+			<md-button class="md-icon-button" style="margin-top: 14px;"
+					   ngclipboard
+					   data-clipboard-text="{{ publicLink }}"
+					   ngclipboard-success="onPublicLinkCopied(e)">
+				<md-icon md-svg-icon="mdi:clipboard-arrow-left"></md-icon>
+				<md-tooltip md-direction="top">
+					{{ 'dashboard.copy-public-link' | translate }}
+				</md-tooltip>
+			</md-button>
+		</div>
+	</div>
 	<fieldset ng-disabled="loading || !isEdit">
 		<md-input-container class="md-block">
 			<label translate>dashboard.title</label>
diff --git a/ui/src/app/dashboard/dashboards.controller.js b/ui/src/app/dashboard/dashboards.controller.js
index b006d95..134baff 100644
--- a/ui/src/app/dashboard/dashboards.controller.js
+++ b/ui/src/app/dashboard/dashboards.controller.js
@@ -19,11 +19,56 @@ import addDashboardTemplate from './add-dashboard.tpl.html';
 import dashboardCard from './dashboard-card.tpl.html';
 import assignToCustomerTemplate from './assign-to-customer.tpl.html';
 import addDashboardsToCustomerTemplate from './add-dashboards-to-customer.tpl.html';
+import makeDashboardPublicDialogTemplate from './make-dashboard-public-dialog.tpl.html';
 
 /* eslint-enable import/no-unresolved, import/default */
 
 /*@ngInject*/
-export default function DashboardsController(userService, dashboardService, customerService, importExport, types, $scope, $controller,
+export function MakeDashboardPublicDialogController($mdDialog, $translate, toast, dashboardService, dashboard) {
+
+    var vm = this;
+
+    vm.dashboard = dashboard;
+    vm.publicLink = dashboardService.getPublicDashboardLink(dashboard);
+
+    vm.onPublicLinkCopied = onPublicLinkCopied;
+    vm.close = close;
+
+    function onPublicLinkCopied(){
+        toast.showSuccess($translate.instant('dashboard.public-link-copied-message'), 750, angular.element('#make-dialog-public-content'), 'bottom left');
+    }
+
+    function close() {
+        $mdDialog.hide();
+    }
+
+}
+
+/*@ngInject*/
+export function DashboardCardController(types) {
+
+    var vm = this;
+
+    vm.types = types;
+
+    vm.isAssignedToCustomer = function() {
+        if (vm.item && vm.item.customerId && vm.parentCtl.dashboardsScope === 'tenant' &&
+            vm.item.customerId.id != vm.types.id.nullUid && !vm.item.assignedCustomer.isPublic) {
+            return true;
+        }
+        return false;
+    }
+
+    vm.isPublic = function() {
+        if (vm.item && vm.item.assignedCustomer && vm.parentCtl.dashboardsScope === 'tenant' && vm.item.assignedCustomer.isPublic) {
+            return true;
+        }
+        return false;
+    }
+}
+
+/*@ngInject*/
+export function DashboardsController(userService, dashboardService, customerService, importExport, types,
                                              $state, $stateParams, $mdDialog, $document, $q, $translate) {
 
     var customerId = $stateParams.customerId;
@@ -58,6 +103,7 @@ export default function DashboardsController(userService, dashboardService, cust
         clickItemFunc: openDashboard,
 
         getItemTitleFunc: getDashboardTitle,
+        itemCardController: 'DashboardCardController',
         itemCardTemplateUrl: dashboardCard,
         parentCtl: vm,
 
@@ -90,6 +136,7 @@ export default function DashboardsController(userService, dashboardService, cust
     vm.dashboardsScope = $state.$current.data.dashboardsType;
 
     vm.assignToCustomer = assignToCustomer;
+    vm.makePublic = makePublic;
     vm.unassignFromCustomer = unassignFromCustomer;
     vm.exportDashboard = exportDashboard;
 
@@ -107,6 +154,17 @@ export default function DashboardsController(userService, dashboardService, cust
             customerId = user.customerId;
         }
 
+        if (customerId) {
+            vm.customerDashboardsTitle = $translate.instant('customer.dashboards');
+            customerService.getShortCustomerInfo(customerId).then(
+                function success(info) {
+                    if (info.isPublic) {
+                        vm.customerDashboardsTitle = $translate.instant('customer.public-dashboards');
+                    }
+                }
+            );
+        }
+
         if (vm.dashboardsScope === 'tenant') {
             fetchDashboardsFunction = function (pageLink) {
                 return dashboardService.getTenantDashboards(pageLink);
@@ -126,8 +184,21 @@ export default function DashboardsController(userService, dashboardService, cust
                     name: function() { $translate.instant('action.export') },
                     details: function() { return $translate.instant('dashboard.export') },
                     icon: "file_download"
-                },
-                {
+                });
+
+            dashboardActionsList.push({
+                    onAction: function ($event, item) {
+                        makePublic($event, item);
+                    },
+                    name: function() { return $translate.instant('action.share') },
+                    details: function() { return $translate.instant('dashboard.make-public') },
+                    icon: "share",
+                    isEnabled: function(dashboard) {
+                        return dashboard && (!dashboard.customerId || dashboard.customerId.id === types.id.nullUid);
+                    }
+                });
+
+            dashboardActionsList.push({
                     onAction: function ($event, item) {
                         assignToCustomer($event, [ item.id.id ]);
                     },
@@ -137,19 +208,29 @@ export default function DashboardsController(userService, dashboardService, cust
                     isEnabled: function(dashboard) {
                         return dashboard && (!dashboard.customerId || dashboard.customerId.id === types.id.nullUid);
                     }
-                },
-                {
+                });
+            dashboardActionsList.push({
                     onAction: function ($event, item) {
-                        unassignFromCustomer($event, item);
+                        unassignFromCustomer($event, item, false);
                     },
                     name: function() { return $translate.instant('action.unassign') },
                     details: function() { return $translate.instant('dashboard.unassign-from-customer') },
                     icon: "assignment_return",
                     isEnabled: function(dashboard) {
-                        return dashboard && dashboard.customerId && dashboard.customerId.id !== types.id.nullUid;
+                        return dashboard && dashboard.customerId && dashboard.customerId.id !== types.id.nullUid && !dashboard.assignedCustomer.isPublic;
                     }
-                }
-            );
+                });
+            dashboardActionsList.push({
+                    onAction: function ($event, item) {
+                        unassignFromCustomer($event, item, true);
+                    },
+                    name: function() { return $translate.instant('action.make-private') },
+                    details: function() { return $translate.instant('dashboard.make-private') },
+                    icon: "reply",
+                    isEnabled: function(dashboard) {
+                        return dashboard && dashboard.customerId && dashboard.customerId.id !== types.id.nullUid && dashboard.assignedCustomer.isPublic;
+                    }
+                });
 
             dashboardActionsList.push(
                 {
@@ -233,11 +314,27 @@ export default function DashboardsController(userService, dashboardService, cust
                 dashboardActionsList.push(
                     {
                         onAction: function ($event, item) {
-                            unassignFromCustomer($event, item);
+                            unassignFromCustomer($event, item, false);
                         },
                         name: function() { return $translate.instant('action.unassign') },
                         details: function() { return $translate.instant('dashboard.unassign-from-customer') },
-                        icon: "assignment_return"
+                        icon: "assignment_return",
+                        isEnabled: function(dashboard) {
+                            return dashboard && !dashboard.assignedCustomer.isPublic;
+                        }
+                    }
+                );
+                dashboardActionsList.push(
+                    {
+                        onAction: function ($event, item) {
+                            unassignFromCustomer($event, item, true);
+                        },
+                        name: function() { return $translate.instant('action.make-private') },
+                        details: function() { return $translate.instant('dashboard.make-private') },
+                        icon: "reply",
+                        isEnabled: function(dashboard) {
+                            return dashboard && dashboard.assignedCustomer.isPublic;
+                        }
                     }
                 );
 
@@ -307,7 +404,28 @@ export default function DashboardsController(userService, dashboardService, cust
     }
 
     function saveDashboard(dashboard) {
-        return dashboardService.saveDashboard(dashboard);
+        var deferred = $q.defer();
+        dashboardService.saveDashboard(dashboard).then(
+            function success(savedDashboard) {
+                var dashboards = [ savedDashboard ];
+                customerService.applyAssignedCustomersInfo(dashboards).then(
+                    function success(items) {
+                        if (items && items.length == 1) {
+                            deferred.resolve(items[0]);
+                        } else {
+                            deferred.reject();
+                        }
+                    },
+                    function fail() {
+                        deferred.reject();
+                    }
+                );
+            },
+            function fail() {
+                deferred.reject();
+            }
+        );
+        return deferred.promise;
     }
 
     function assignToCustomer($event, dashboardIds) {
@@ -389,15 +507,27 @@ export default function DashboardsController(userService, dashboardService, cust
         assignToCustomer($event, dashboardIds);
     }
 
-    function unassignFromCustomer($event, dashboard) {
+    function unassignFromCustomer($event, dashboard, isPublic) {
         if ($event) {
             $event.stopPropagation();
         }
+        var title;
+        var content;
+        var label;
+        if (isPublic) {
+            title = $translate.instant('dashboard.make-private-dashboard-title', {dashboardTitle: dashboard.title});
+            content = $translate.instant('dashboard.make-private-dashboard-text');
+            label = $translate.instant('dashboard.make-private-dashboard');
+        } else {
+            title = $translate.instant('dashboard.unassign-dashboard-title', {dashboardTitle: dashboard.title});
+            content = $translate.instant('dashboard.unassign-dashboard-text');
+            label = $translate.instant('dashboard.unassign-dashboard');
+        }
         var confirm = $mdDialog.confirm()
             .targetEvent($event)
-            .title($translate.instant('dashboard.unassign-dashboard-title', {dashboardTitle: dashboard.title}))
-            .htmlContent($translate.instant('dashboard.unassign-dashboard-text'))
-            .ariaLabel($translate.instant('dashboard.unassign-dashboard'))
+            .title(title)
+            .htmlContent(content)
+            .ariaLabel(label)
             .cancel($translate.instant('action.no'))
             .ok($translate.instant('action.yes'));
         $mdDialog.show(confirm).then(function () {
@@ -407,6 +537,25 @@ export default function DashboardsController(userService, dashboardService, cust
         });
     }
 
+    function makePublic($event, dashboard) {
+        if ($event) {
+            $event.stopPropagation();
+        }
+        dashboardService.makeDashboardPublic(dashboard.id.id).then(function success(dashboard) {
+            $mdDialog.show({
+                controller: 'MakeDashboardPublicDialogController',
+                controllerAs: 'vm',
+                templateUrl: makeDashboardPublicDialogTemplate,
+                locals: {dashboard: dashboard},
+                parent: angular.element($document[0].body),
+                fullscreen: true,
+                targetEvent: $event
+            }).then(function () {
+                vm.grid.refreshList();
+            });
+        });
+    }
+
     function exportDashboard($event, dashboard) {
         $event.stopPropagation();
         importExport.exportDashboard(dashboard.id.id);
diff --git a/ui/src/app/dashboard/dashboards.tpl.html b/ui/src/app/dashboard/dashboards.tpl.html
index bae57b9..dde2f86 100644
--- a/ui/src/app/dashboard/dashboards.tpl.html
+++ b/ui/src/app/dashboard/dashboards.tpl.html
@@ -24,7 +24,8 @@
 						  dashboard-scope="vm.dashboardsScope"
 						  the-form="vm.grid.detailsForm"
 						  on-assign-to-customer="vm.assignToCustomer(event, [ vm.grid.detailsConfig.currentItem.id.id ])"
-						  on-unassign-from-customer="vm.unassignFromCustomer(event, vm.grid.detailsConfig.currentItem)"
+						  on-make-public="vm.makePublic(event, vm.grid.detailsConfig.currentItem)"
+						  on-unassign-from-customer="vm.unassignFromCustomer(event, vm.grid.detailsConfig.currentItem, isPublic)"
 						  on-export-dashboard="vm.exportDashboard(event, vm.grid.detailsConfig.currentItem)"
 						  on-delete-dashboard="vm.grid.deleteItem(event, vm.grid.detailsConfig.currentItem)"></tb-dashboard-details>
 </tb-grid>
diff --git a/ui/src/app/dashboard/dashboard-settings.controller.js b/ui/src/app/dashboard/dashboard-settings.controller.js
index aac6da3..06c64eb 100644
--- a/ui/src/app/dashboard/dashboard-settings.controller.js
+++ b/ui/src/app/dashboard/dashboard-settings.controller.js
@@ -31,6 +31,18 @@ export default function DashboardSettingsController($scope, $mdDialog, gridSetti
         vm.gridSettings.showTitle = true;
     }
 
+    if (angular.isUndefined(vm.gridSettings.showDevicesSelect)) {
+        vm.gridSettings.showDevicesSelect = true;
+    }
+
+    if (angular.isUndefined(vm.gridSettings.showDashboardTimewindow)) {
+        vm.gridSettings.showDashboardTimewindow = true;
+    }
+
+    if (angular.isUndefined(vm.gridSettings.showDashboardExport)) {
+        vm.gridSettings.showDashboardExport = true;
+    }
+
     vm.gridSettings.backgroundColor = vm.gridSettings.backgroundColor || 'rgba(0,0,0,0)';
     vm.gridSettings.titleColor = vm.gridSettings.titleColor || 'rgba(0,0,0,0.870588)';
     vm.gridSettings.columns = vm.gridSettings.columns || 24;
diff --git a/ui/src/app/dashboard/dashboard-settings.tpl.html b/ui/src/app/dashboard/dashboard-settings.tpl.html
index 2ed25d3..ec6f28b 100644
--- a/ui/src/app/dashboard/dashboard-settings.tpl.html
+++ b/ui/src/app/dashboard/dashboard-settings.tpl.html
@@ -48,6 +48,17 @@
                              md-color-history="false"
                         ></div>
                     </div>
+                    <div layout="row" layout-align="start center">
+                        <md-checkbox flex aria-label="{{ 'dashboard.display-device-selection' | translate }}"
+                                     ng-model="vm.gridSettings.showDevicesSelect">{{ 'dashboard.display-device-selection' | translate }}
+                        </md-checkbox>
+                        <md-checkbox flex aria-label="{{ 'dashboard.display-dashboard-timewindow' | translate }}"
+                                     ng-model="vm.gridSettings.showDashboardTimewindow">{{ 'dashboard.display-dashboard-timewindow' | translate }}
+                        </md-checkbox>
+                        <md-checkbox flex aria-label="{{ 'dashboard.display-dashboard-export' | translate }}"
+                                     ng-model="vm.gridSettings.showDashboardExport">{{ 'dashboard.display-dashboard-export' | translate }}
+                        </md-checkbox>
+                    </div>
                     <md-input-container class="md-block">
                         <label translate>dashboard.columns-count</label>
                         <input required type="number" step="any" name="columns" ng-model="vm.gridSettings.columns" min="10"
diff --git a/ui/src/app/dashboard/edit-widget.directive.js b/ui/src/app/dashboard/edit-widget.directive.js
index 1c2e461..ced39f5 100644
--- a/ui/src/app/dashboard/edit-widget.directive.js
+++ b/ui/src/app/dashboard/edit-widget.directive.js
@@ -37,6 +37,7 @@ export default function EditWidgetDirective($compile, $templateCache, widgetServ
                             scope.widgetConfig = scope.widget.config;
                             var settingsSchema = widgetInfo.typeSettingsSchema || widgetInfo.settingsSchema;
                             var dataKeySettingsSchema = widgetInfo.typeDataKeySettingsSchema || widgetInfo.dataKeySettingsSchema;
+                            scope.isDataEnabled = !widgetInfo.useCustomDatasources;
                             if (!settingsSchema || settingsSchema === '') {
                                 scope.settingsSchema = {};
                             } else {
diff --git a/ui/src/app/dashboard/edit-widget.tpl.html b/ui/src/app/dashboard/edit-widget.tpl.html
index 7aa6339..1aaf861 100644
--- a/ui/src/app/dashboard/edit-widget.tpl.html
+++ b/ui/src/app/dashboard/edit-widget.tpl.html
@@ -18,6 +18,7 @@
 <fieldset ng-disabled="loading">
 	<tb-widget-config widget-type="widget.type"
 					  ng-model="widgetConfig"
+					  is-data-enabled="isDataEnabled"
 					  widget-settings-schema="settingsSchema"
 					  datakey-settings-schema="dataKeySettingsSchema"
 					  device-aliases="aliasesInfo.deviceAliases"
diff --git a/ui/src/app/dashboard/index.js b/ui/src/app/dashboard/index.js
index 83dc850..88d71fb 100644
--- a/ui/src/app/dashboard/index.js
+++ b/ui/src/app/dashboard/index.js
@@ -30,12 +30,13 @@ import thingsboardDashboardSelect from '../components/dashboard-select.directive
 import thingsboardDashboard from '../components/dashboard.directive';
 import thingsboardExpandFullscreen from '../components/expand-fullscreen.directive';
 import thingsboardWidgetsBundleSelect from '../components/widgets-bundle-select.directive';
+import thingsboardSocialsharePanel from '../components/socialshare-panel.directive';
 import thingsboardTypes from '../common/types.constant';
 import thingsboardItemBuffer from '../services/item-buffer.service';
 import thingsboardImportExport from '../import-export';
 
 import DashboardRoutes from './dashboard.routes';
-import DashboardsController from './dashboards.controller';
+import {DashboardsController, DashboardCardController, MakeDashboardPublicDialogController} from './dashboards.controller';
 import DashboardController from './dashboard.controller';
 import DeviceAliasesController from './device-aliases.controller';
 import AliasesDeviceSelectPanelController from './aliases-device-select-panel.controller';
@@ -64,10 +65,13 @@ export default angular.module('thingsboard.dashboard', [
     thingsboardDashboardSelect,
     thingsboardDashboard,
     thingsboardExpandFullscreen,
-    thingsboardWidgetsBundleSelect
+    thingsboardWidgetsBundleSelect,
+    thingsboardSocialsharePanel
 ])
     .config(DashboardRoutes)
     .controller('DashboardsController', DashboardsController)
+    .controller('DashboardCardController', DashboardCardController)
+    .controller('MakeDashboardPublicDialogController', MakeDashboardPublicDialogController)
     .controller('DashboardController', DashboardController)
     .controller('DeviceAliasesController', DeviceAliasesController)
     .controller('AliasesDeviceSelectPanelController', AliasesDeviceSelectPanelController)
diff --git a/ui/src/app/dashboard/make-dashboard-public-dialog.tpl.html b/ui/src/app/dashboard/make-dashboard-public-dialog.tpl.html
new file mode 100644
index 0000000..8d96b30
--- /dev/null
+++ b/ui/src/app/dashboard/make-dashboard-public-dialog.tpl.html
@@ -0,0 +1,62 @@
+<!--
+
+    Copyright © 2016-2017 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.
+
+-->
+<md-dialog aria-label="{{ 'dashboard.make-public' | translate }}" style="min-width: 400px;">
+    <form>
+        <md-toolbar>
+            <div class="md-toolbar-tools">
+                <h2 translate="dashboard.public-dashboard-title"></h2>
+                <span flex></span>
+                <md-button class="md-icon-button" ng-click="vm.close()">
+                    <ng-md-icon icon="close" aria-label="{{ 'dialog.close' | translate }}"></ng-md-icon>
+                </md-button>
+            </div>
+        </md-toolbar>
+        <md-dialog-content>
+            <div id="make-dialog-public-content" class="md-dialog-content">
+                <md-content class="md-padding" layout="column">
+                    <span translate="dashboard.public-dashboard-text" translate-values="{dashboardTitle: vm.dashboard.title, publicLink: vm.publicLink}"></span>
+                    <div layout="row" layout-align="start center">
+                        <pre class="tb-highlight" flex><code>{{ vm.publicLink }}</code></pre>
+                        <md-button class="md-icon-button"
+                                   ngclipboard
+                                   data-clipboard-text="{{ vm.publicLink }}"
+                                   ngclipboard-success="vm.onPublicLinkCopied(e)">
+                            <md-icon md-svg-icon="mdi:clipboard-arrow-left"></md-icon>
+                            <md-tooltip md-direction="top">
+                                {{ 'dashboard.copy-public-link' | translate }}
+                            </md-tooltip>
+                        </md-button>
+                    </div>
+                    <div class="tb-notice" translate>dashboard.public-dashboard-notice</div>
+                    <tb-social-share-panel style="padding-top: 15px;"
+                            share-title="{{ 'dashboard.socialshare-title' | translate:{dashboardTitle:vm.dashboard.title} }}"
+                            share-text="{{ 'dashboard.socialshare-text' | translate:{dashboardTitle:vm.dashboard.title} }}"
+                            share-link="{{ vm.publicLink }}"
+                            share-hash-tags="thingsboard, iot">
+                    </tb-social-share-panel>
+                </md-content>
+            </div>
+        </md-dialog-content>
+        <md-dialog-actions layout="row">
+            <span flex></span>
+            <md-button ng-click="vm.close()">{{ 'action.ok' |
+                translate }}
+            </md-button>
+        </md-dialog-actions>
+    </form>
+</md-dialog>
diff --git a/ui/src/app/device/add-devices-to-customer.controller.js b/ui/src/app/device/add-devices-to-customer.controller.js
index 9fd0cec..c54daab 100644
--- a/ui/src/app/device/add-devices-to-customer.controller.js
+++ b/ui/src/app/device/add-devices-to-customer.controller.js
@@ -52,7 +52,7 @@ export default function AddDevicesToCustomerController(deviceService, $mdDialog,
         fetchMoreItems_: function () {
             if (vm.devices.hasNext && !vm.devices.pending) {
                 vm.devices.pending = true;
-                deviceService.getTenantDevices(vm.devices.nextPageLink).then(
+                deviceService.getTenantDevices(vm.devices.nextPageLink, false).then(
                     function success(devices) {
                         vm.devices.data = vm.devices.data.concat(devices.data);
                         vm.devices.nextPageLink = devices.nextPageLink;
diff --git a/ui/src/app/device/attribute/attribute-table.directive.js b/ui/src/app/device/attribute/attribute-table.directive.js
index 560a4dc..701fd37 100644
--- a/ui/src/app/device/attribute/attribute-table.directive.js
+++ b/ui/src/app/device/attribute/attribute-table.directive.js
@@ -72,7 +72,7 @@ export default function AttributeTableDirective($compile, $templateCache, $rootS
         scope.$watch("deviceId", function(newVal, prevVal) {
             if (newVal && !angular.equals(newVal, prevVal)) {
                 scope.resetFilter();
-                scope.getDeviceAttributes();
+                scope.getDeviceAttributes(false, true);
             }
         });
 
@@ -81,7 +81,7 @@ export default function AttributeTableDirective($compile, $templateCache, $rootS
                 scope.mode = 'default';
                 scope.query.search = null;
                 scope.selectedAttributes = [];
-                scope.getDeviceAttributes();
+                scope.getDeviceAttributes(false, true);
             }
         });
 
@@ -117,15 +117,25 @@ export default function AttributeTableDirective($compile, $templateCache, $rootS
             }
         }
 
-        scope.getDeviceAttributes = function(forceUpdate) {
+        scope.onReorder = function() {
+            scope.getDeviceAttributes(false, false);
+        }
+
+        scope.onPaginate = function() {
+            scope.getDeviceAttributes(false, false);
+        }
+
+        scope.getDeviceAttributes = function(forceUpdate, reset) {
             if (scope.attributesDeferred) {
                 scope.attributesDeferred.resolve();
             }
             if (scope.deviceId && scope.attributeScope) {
-                scope.attributes = {
-                    count: 0,
-                    data: []
-                };
+                if (reset) {
+                    scope.attributes = {
+                        count: 0,
+                        data: []
+                    };
+                }
                 scope.checkSubscription();
                 scope.attributesDeferred = deviceService.getDeviceAttributes(scope.deviceId, scope.attributeScope.value,
                     scope.query, function(attributes, update, apply) {
diff --git a/ui/src/app/device/attribute/attribute-table.tpl.html b/ui/src/app/device/attribute/attribute-table.tpl.html
index 915fbea..b6099c4 100644
--- a/ui/src/app/device/attribute/attribute-table.tpl.html
+++ b/ui/src/app/device/attribute/attribute-table.tpl.html
@@ -126,7 +126,7 @@
         </md-toolbar>
         <md-table-container ng-show="mode!='widget'">
             <table md-table md-row-select multiple="" ng-model="selectedAttributes" md-progress="attributesDeferred.promise">
-                <thead md-head md-order="query.order" md-on-reorder="getDeviceAttributes">
+                <thead md-head md-order="query.order" md-on-reorder="onReorder">
                     <tr md-row>
                         <th md-column md-order-by="lastUpdateTs"><span>Last update time</span></th>
                         <th md-column md-order-by="key"><span>Key</span></th>
@@ -147,7 +147,7 @@
         </md-table-container>
         <md-table-pagination ng-show="mode!='widget'" md-limit="query.limit" md-limit-options="[5, 10, 15]"
                              md-page="query.page" md-total="{{attributes.count}}"
-                             md-on-paginate="getDeviceAttributes" md-page-select>
+                             md-on-paginate="onPaginate" md-page-select>
         </md-table-pagination>
         <ul flex rn-carousel ng-if="mode==='widget'" class="widgets-carousel"
             rn-carousel-index="widgetsCarousel.index"
diff --git a/ui/src/app/device/device.controller.js b/ui/src/app/device/device.controller.js
index d111df8..55b7083 100644
--- a/ui/src/app/device/device.controller.js
+++ b/ui/src/app/device/device.controller.js
@@ -24,7 +24,31 @@ import deviceCredentialsTemplate from './device-credentials.tpl.html';
 /* eslint-enable import/no-unresolved, import/default */
 
 /*@ngInject*/
-export default function DeviceController(userService, deviceService, customerService, $scope, $controller, $state, $stateParams, $document, $mdDialog, $q, $translate, types) {
+export function DeviceCardController(types) {
+
+    var vm = this;
+
+    vm.types = types;
+
+    vm.isAssignedToCustomer = function() {
+        if (vm.item && vm.item.customerId && vm.parentCtl.devicesScope === 'tenant' &&
+            vm.item.customerId.id != vm.types.id.nullUid && !vm.item.assignedCustomer.isPublic) {
+            return true;
+        }
+        return false;
+    }
+
+    vm.isPublic = function() {
+        if (vm.item && vm.item.assignedCustomer && vm.parentCtl.devicesScope === 'tenant' && vm.item.assignedCustomer.isPublic) {
+            return true;
+        }
+        return false;
+    }
+}
+
+
+/*@ngInject*/
+export function DeviceController(userService, deviceService, customerService, $state, $stateParams, $document, $mdDialog, $q, $translate, types) {
 
     var customerId = $stateParams.customerId;
 
@@ -47,6 +71,7 @@ export default function DeviceController(userService, deviceService, customerSer
 
         getItemTitleFunc: getDeviceTitle,
 
+        itemCardController: 'DeviceCardController',
         itemCardTemplateUrl: deviceCard,
         parentCtl: vm,
 
@@ -77,6 +102,7 @@ export default function DeviceController(userService, deviceService, customerSer
     vm.devicesScope = $state.$current.data.devicesType;
 
     vm.assignToCustomer = assignToCustomer;
+    vm.makePublic = makePublic;
     vm.unassignFromCustomer = unassignFromCustomer;
     vm.manageCredentials = manageCredentials;
 
@@ -93,10 +119,20 @@ export default function DeviceController(userService, deviceService, customerSer
             vm.devicesScope = 'customer_user';
             customerId = user.customerId;
         }
+        if (customerId) {
+            vm.customerDevicesTitle = $translate.instant('customer.devices');
+            customerService.getShortCustomerInfo(customerId).then(
+                function success(info) {
+                    if (info.isPublic) {
+                        vm.customerDevicesTitle = $translate.instant('customer.public-devices');
+                    }
+                }
+            );
+        }
 
         if (vm.devicesScope === 'tenant') {
             fetchDevicesFunction = function (pageLink) {
-                return deviceService.getTenantDevices(pageLink);
+                return deviceService.getTenantDevices(pageLink, true);
             };
             deleteDeviceFunction = function (deviceId) {
                 return deviceService.deleteDevice(deviceId);
@@ -105,6 +141,18 @@ export default function DeviceController(userService, deviceService, customerSer
                 return {"topIndex": vm.topIndex};
             };
 
+            deviceActionsList.push({
+                onAction: function ($event, item) {
+                    makePublic($event, item);
+                },
+                name: function() { return $translate.instant('action.share') },
+                details: function() { return $translate.instant('device.make-public') },
+                icon: "share",
+                isEnabled: function(device) {
+                    return device && (!device.customerId || device.customerId.id === types.id.nullUid);
+                }
+            });
+
             deviceActionsList.push(
                 {
                     onAction: function ($event, item) {
@@ -122,17 +170,29 @@ export default function DeviceController(userService, deviceService, customerSer
             deviceActionsList.push(
                 {
                     onAction: function ($event, item) {
-                        unassignFromCustomer($event, item);
+                        unassignFromCustomer($event, item, false);
                     },
                     name: function() { return $translate.instant('action.unassign') },
                     details: function() { return $translate.instant('device.unassign-from-customer') },
                     icon: "assignment_return",
                     isEnabled: function(device) {
-                        return device && device.customerId && device.customerId.id !== types.id.nullUid;
+                        return device && device.customerId && device.customerId.id !== types.id.nullUid && !device.assignedCustomer.isPublic;
                     }
                 }
             );
 
+            deviceActionsList.push({
+                onAction: function ($event, item) {
+                    unassignFromCustomer($event, item, true);
+                },
+                name: function() { return $translate.instant('action.make-private') },
+                details: function() { return $translate.instant('device.make-private') },
+                icon: "reply",
+                isEnabled: function(device) {
+                    return device && device.customerId && device.customerId.id !== types.id.nullUid && device.assignedCustomer.isPublic;
+                }
+            });
+
             deviceActionsList.push(
                 {
                     onAction: function ($event, item) {
@@ -183,7 +243,7 @@ export default function DeviceController(userService, deviceService, customerSer
 
         } else if (vm.devicesScope === 'customer' || vm.devicesScope === 'customer_user') {
             fetchDevicesFunction = function (pageLink) {
-                return deviceService.getCustomerDevices(customerId, pageLink);
+                return deviceService.getCustomerDevices(customerId, pageLink, true);
             };
             deleteDeviceFunction = function (deviceId) {
                 return deviceService.unassignDeviceFromCustomer(deviceId);
@@ -196,16 +256,33 @@ export default function DeviceController(userService, deviceService, customerSer
                 deviceActionsList.push(
                     {
                         onAction: function ($event, item) {
-                            unassignFromCustomer($event, item);
+                            unassignFromCustomer($event, item, false);
                         },
                         name: function() { return $translate.instant('action.unassign') },
                         details: function() { return $translate.instant('device.unassign-from-customer') },
-                        icon: "assignment_return"
+                        icon: "assignment_return",
+                        isEnabled: function(device) {
+                            return device && !device.assignedCustomer.isPublic;
+                        }
                     }
                 );
                 deviceActionsList.push(
                     {
                         onAction: function ($event, item) {
+                            unassignFromCustomer($event, item, true);
+                        },
+                        name: function() { return $translate.instant('action.make-private') },
+                        details: function() { return $translate.instant('device.make-private') },
+                        icon: "reply",
+                        isEnabled: function(device) {
+                            return device && device.assignedCustomer.isPublic;
+                        }
+                    }
+                );
+
+                deviceActionsList.push(
+                    {
+                        onAction: function ($event, item) {
                             manageCredentials($event, item);
                         },
                         name: function() { return $translate.instant('device.credentials') },
@@ -287,8 +364,29 @@ export default function DeviceController(userService, deviceService, customerSer
         return device ? device.name : '';
     }
 
-    function saveDevice (device) {
-        return deviceService.saveDevice(device);
+    function saveDevice(device) {
+        var deferred = $q.defer();
+        deviceService.saveDevice(device).then(
+            function success(savedDevice) {
+                var devices = [ savedDevice ];
+                customerService.applyAssignedCustomersInfo(devices).then(
+                    function success(items) {
+                        if (items && items.length == 1) {
+                            deferred.resolve(items[0]);
+                        } else {
+                            deferred.reject();
+                        }
+                    },
+                    function fail() {
+                        deferred.reject();
+                    }
+                );
+            },
+            function fail() {
+                deferred.reject();
+            }
+        );
+        return deferred.promise;
     }
 
     function isCustomerUser() {
@@ -335,7 +433,7 @@ export default function DeviceController(userService, deviceService, customerSer
             $event.stopPropagation();
         }
         var pageSize = 10;
-        deviceService.getTenantDevices({limit: pageSize, textSearch: ''}).then(
+        deviceService.getTenantDevices({limit: pageSize, textSearch: ''}, false).then(
             function success(_devices) {
                 var devices = {
                     pageSize: pageSize,
@@ -374,15 +472,27 @@ export default function DeviceController(userService, deviceService, customerSer
         assignToCustomer($event, deviceIds);
     }
 
-    function unassignFromCustomer($event, device) {
+    function unassignFromCustomer($event, device, isPublic) {
         if ($event) {
             $event.stopPropagation();
         }
+        var title;
+        var content;
+        var label;
+        if (isPublic) {
+            title = $translate.instant('device.make-private-device-title', {deviceName: device.name});
+            content = $translate.instant('device.make-private-device-text');
+            label = $translate.instant('device.make-private');
+        } else {
+            title = $translate.instant('device.unassign-device-title', {deviceName: device.name});
+            content = $translate.instant('device.unassign-device-text');
+            label = $translate.instant('device.unassign-device');
+        }
         var confirm = $mdDialog.confirm()
             .targetEvent($event)
-            .title($translate.instant('device.unassign-device-title', {deviceName: device.name}))
-            .htmlContent($translate.instant('device.unassign-device-text'))
-            .ariaLabel($translate.instant('device.unassign-device'))
+            .title(title)
+            .htmlContent(content)
+            .ariaLabel(label)
             .cancel($translate.instant('action.no'))
             .ok($translate.instant('action.yes'));
         $mdDialog.show(confirm).then(function () {
@@ -411,6 +521,24 @@ export default function DeviceController(userService, deviceService, customerSer
         });
     }
 
+    function makePublic($event, device) {
+        if ($event) {
+            $event.stopPropagation();
+        }
+        var confirm = $mdDialog.confirm()
+            .targetEvent($event)
+            .title($translate.instant('device.make-public-device-title', {deviceName: device.name}))
+            .htmlContent($translate.instant('device.make-public-device-text'))
+            .ariaLabel($translate.instant('device.make-public'))
+            .cancel($translate.instant('action.no'))
+            .ok($translate.instant('action.yes'));
+        $mdDialog.show(confirm).then(function () {
+            deviceService.makeDevicePublic(device.id.id).then(function success() {
+                vm.grid.refreshList();
+            });
+        });
+    }
+
     function manageCredentials($event, device) {
         if ($event) {
             $event.stopPropagation();
diff --git a/ui/src/app/device/device.directive.js b/ui/src/app/device/device.directive.js
index 9c927ae..eb50a15 100644
--- a/ui/src/app/device/device.directive.js
+++ b/ui/src/app/device/device.directive.js
@@ -26,6 +26,7 @@ export default function DeviceDirective($compile, $templateCache, toast, $transl
         element.html(template);
 
         scope.isAssignedToCustomer = false;
+        scope.isPublic = false;
         scope.assignedCustomer = null;
 
         scope.deviceCredentials = null;
@@ -41,13 +42,15 @@ export default function DeviceDirective($compile, $templateCache, toast, $transl
                 }
                 if (scope.device.customerId && scope.device.customerId.id !== types.id.nullUid) {
                     scope.isAssignedToCustomer = true;
-                    customerService.getCustomer(scope.device.customerId.id).then(
+                    customerService.getShortCustomerInfo(scope.device.customerId.id).then(
                         function success(customer) {
                             scope.assignedCustomer = customer;
+                            scope.isPublic = customer.isPublic;
                         }
                     );
                 } else {
                     scope.isAssignedToCustomer = false;
+                    scope.isPublic = false;
                     scope.assignedCustomer = null;
                 }
             }
@@ -72,6 +75,7 @@ export default function DeviceDirective($compile, $templateCache, toast, $transl
             deviceScope: '=',
             theForm: '=',
             onAssignToCustomer: '&',
+            onMakePublic: '&',
             onUnassignFromCustomer: '&',
             onManageCredentials: '&',
             onDeleteDevice: '&'
diff --git a/ui/src/app/device/device.routes.js b/ui/src/app/device/device.routes.js
index d0293b4..c1f5b27 100644
--- a/ui/src/app/device/device.routes.js
+++ b/ui/src/app/device/device.routes.js
@@ -61,7 +61,7 @@ export default function DeviceRoutes($stateProvider) {
                 pageTitle: 'customer.devices'
             },
             ncyBreadcrumb: {
-                label: '{"icon": "devices_other", "label": "customer.devices"}'
+                label: '{"icon": "devices_other", "label": "{{ vm.customerDevicesTitle }}", "translate": "false"}'
             }
         });
 
diff --git a/ui/src/app/device/device-card.tpl.html b/ui/src/app/device/device-card.tpl.html
index f11cd2b..bb84af7 100644
--- a/ui/src/app/device/device-card.tpl.html
+++ b/ui/src/app/device/device-card.tpl.html
@@ -15,6 +15,5 @@
     limitations under the License.
 
 -->
-<div class="tb-small" ng-if="item &&
-            item.customerId.id != parentCtl.types.id.nullUid &&
-            parentCtl.devicesScope === 'tenant'" translate>device.assignedToCustomer</div>
+<div class="tb-small" ng-show="vm.isAssignedToCustomer()">{{'device.assignedToCustomer' | translate}} '{{vm.item.assignedCustomer.title}}'</div>
+<div class="tb-small" ng-show="vm.isPublic()">{{'device.public' | translate}}</div>
diff --git a/ui/src/app/device/device-fieldset.tpl.html b/ui/src/app/device/device-fieldset.tpl.html
index 99cedbd..fd6c9d1 100644
--- a/ui/src/app/device/device-fieldset.tpl.html
+++ b/ui/src/app/device/device-fieldset.tpl.html
@@ -15,12 +15,15 @@
     limitations under the License.
 
 -->
+<md-button ng-click="onMakePublic({event: $event})"
+           ng-show="!isEdit && deviceScope === 'tenant' && !isAssignedToCustomer && !isPublic"
+           class="md-raised md-primary">{{ 'device.make-public' | translate }}</md-button>
 <md-button ng-click="onAssignToCustomer({event: $event})"
            ng-show="!isEdit && deviceScope === 'tenant' && !isAssignedToCustomer"
            class="md-raised md-primary">{{ 'device.assign-to-customer' | translate }}</md-button>
-<md-button ng-click="onUnassignFromCustomer({event: $event})"
+<md-button ng-click="onUnassignFromCustomer({event: $event, isPublic: isPublic})"
            ng-show="!isEdit && (deviceScope === 'customer' || deviceScope === 'tenant') && isAssignedToCustomer"
-           class="md-raised md-primary">{{ 'device.unassign-from-customer' | translate }}</md-button>
+           class="md-raised md-primary">{{ isPublic ? 'device.make-private' : 'device.unassign-from-customer' | translate }}</md-button>
 <md-button ng-click="onManageCredentials({event: $event})"
            ng-show="!isEdit"
            class="md-raised md-primary">{{ (deviceScope === 'customer_user' ? 'device.view-credentials' : 'device.manage-credentials') | translate }}</md-button>
@@ -47,10 +50,14 @@
 
 <md-content class="md-padding" layout="column">
     <md-input-container class="md-block"
-                        ng-show="isAssignedToCustomer && deviceScope === 'tenant'">
+                        ng-show="!isEdit && isAssignedToCustomer && !isPublic && deviceScope === 'tenant'">
         <label translate>device.assignedToCustomer</label>
         <input ng-model="assignedCustomer.title" disabled>
     </md-input-container>
+    <div class="tb-small" style="padding-bottom: 10px; padding-left: 2px;"
+         ng-show="!isEdit && isPublic && (deviceScope === 'customer' || deviceScope === 'tenant')">
+        {{ 'device.device-public' | translate }}
+    </div>
 	<fieldset ng-disabled="loading || !isEdit">
 		<md-input-container class="md-block">
 			<label translate>device.name</label>
diff --git a/ui/src/app/device/devices.tpl.html b/ui/src/app/device/devices.tpl.html
index 52c05f2..90909e3 100644
--- a/ui/src/app/device/devices.tpl.html
+++ b/ui/src/app/device/devices.tpl.html
@@ -27,7 +27,8 @@
                        device-scope="vm.devicesScope"
                        the-form="vm.grid.detailsForm"
                        on-assign-to-customer="vm.assignToCustomer(event, [ vm.grid.detailsConfig.currentItem.id.id ])"
-                       on-unassign-from-customer="vm.unassignFromCustomer(event, vm.grid.detailsConfig.currentItem)"
+                       on-make-public="vm.makePublic(event, vm.grid.detailsConfig.currentItem)"
+                       on-unassign-from-customer="vm.unassignFromCustomer(event, vm.grid.detailsConfig.currentItem, isPublic)"
                        on-manage-credentials="vm.manageCredentials(event, vm.grid.detailsConfig.currentItem)"
                        on-delete-device="vm.grid.deleteItem(event, vm.grid.detailsConfig.currentItem)"></tb-device>
         </md-tab>
diff --git a/ui/src/app/device/index.js b/ui/src/app/device/index.js
index 611e664..b84497a 100644
--- a/ui/src/app/device/index.js
+++ b/ui/src/app/device/index.js
@@ -21,7 +21,7 @@ import thingsboardApiDevice from '../api/device.service';
 import thingsboardApiCustomer from '../api/customer.service';
 
 import DeviceRoutes from './device.routes';
-import DeviceController from './device.controller';
+import {DeviceController, DeviceCardController} from './device.controller';
 import AssignDeviceToCustomerController from './assign-to-customer.controller';
 import AddDevicesToCustomerController from './add-devices-to-customer.controller';
 import ManageDeviceCredentialsController from './device-credentials.controller';
@@ -40,6 +40,7 @@ export default angular.module('thingsboard.device', [
 ])
     .config(DeviceRoutes)
     .controller('DeviceController', DeviceController)
+    .controller('DeviceCardController', DeviceCardController)
     .controller('AssignDeviceToCustomerController', AssignDeviceToCustomerController)
     .controller('AddDevicesToCustomerController', AddDevicesToCustomerController)
     .controller('ManageDeviceCredentialsController', ManageDeviceCredentialsController)
diff --git a/ui/src/app/global-interceptor.service.js b/ui/src/app/global-interceptor.service.js
index 8ba6c04..86cd676 100644
--- a/ui/src/app/global-interceptor.service.js
+++ b/ui/src/app/global-interceptor.service.js
@@ -111,11 +111,12 @@ export default function GlobalInterceptor($rootScope, $q, $injector) {
     function request(config) {
         var rejected = false;
         if (config.url.startsWith('/api/')) {
-            $rootScope.loading = !isInternalUrlPrefix(config.url);
+            var isLoading = !isInternalUrlPrefix(config.url);
+            updateLoadingState(config, isLoading);
             if (isTokenBasedAuthEntryPoint(config.url)) {
                 if (!getUserService().updateAuthorizationHeader(config.headers) &&
                     !getUserService().refreshTokenPending()) {
-                    $rootScope.loading = false;
+                    updateLoadingState(config, false);
                     rejected = true;
                     getUserService().clearJwtToken(false);
                     return $q.reject({ data: {message: getTranslate().instant('access.unauthorized')}, status: 401, config: config});
@@ -131,21 +132,21 @@ export default function GlobalInterceptor($rootScope, $q, $injector) {
 
     function requestError(rejection) {
         if (rejection.config.url.startsWith('/api/')) {
-            $rootScope.loading = false;
+            updateLoadingState(rejection.config, false);
         }
         return $q.reject(rejection);
     }
 
     function response(response) {
         if (response.config.url.startsWith('/api/')) {
-            $rootScope.loading = false;
+            updateLoadingState(response.config, false);
         }
         return response;
     }
 
     function responseError(rejection) {
         if (rejection.config.url.startsWith('/api/')) {
-            $rootScope.loading = false;
+            updateLoadingState(rejection.config, false);
         }
         var unhandled = false;
         var ignoreErrors = rejection.config.ignoreErrors;
@@ -184,4 +185,10 @@ export default function GlobalInterceptor($rootScope, $q, $injector) {
         }
         return $q.reject(rejection);
     }
+
+    function updateLoadingState(config, isLoading) {
+        if (!config || angular.isUndefined(config.ignoreLoading) || !config.ignoreLoading) {
+            $rootScope.loading = isLoading;
+        }
+    }
 }
diff --git a/ui/src/app/layout/home.controller.js b/ui/src/app/layout/home.controller.js
index 290cacf..4979501 100644
--- a/ui/src/app/layout/home.controller.js
+++ b/ui/src/app/layout/home.controller.js
@@ -26,7 +26,7 @@ import logoSvg from '../../svg/logo_title_white.svg';
 
 /*@ngInject*/
 export default function HomeController(loginService, userService, deviceService, Fullscreen, $scope, $element, $rootScope, $document, $state,
-                                       $log, $mdMedia, $animate, $timeout) {
+                                       $window, $log, $mdMedia, $animate, $timeout) {
 
     var siteSideNav = $('.tb-site-sidenav', $element);
 
@@ -48,6 +48,7 @@ export default function HomeController(loginService, userService, deviceService,
 
     vm.displaySearchMode = displaySearchMode;
     vm.openSidenav = openSidenav;
+    vm.goBack = goBack;
     vm.searchTextUpdated = searchTextUpdated;
     vm.sidenavClicked = sidenavClicked;
     vm.toggleFullscreen = toggleFullscreen;
@@ -104,6 +105,10 @@ export default function HomeController(loginService, userService, deviceService,
         vm.isShowSidenav = true;
     }
 
+    function goBack() {
+        $window.history.back();
+    }
+
     function closeSidenav() {
         vm.isShowSidenav = false;
     }
diff --git a/ui/src/app/layout/home.tpl.html b/ui/src/app/layout/home.tpl.html
index 31c8e7f..bfb37eb 100644
--- a/ui/src/app/layout/home.tpl.html
+++ b/ui/src/app/layout/home.tpl.html
@@ -45,6 +45,10 @@
 		      		class="md-icon-button" ng-click="vm.openSidenav()" aria-label="{{ 'home.menu' | translate }}" ng-class="{'tb-invisible': vm.displaySearchMode()}">
 		      		<md-icon aria-label="{{ 'home.menu' | translate }}" class="material-icons">menu</md-icon>
 		      </md-button>
+			  <md-button ng-show="forceFullscreen"
+					   class="md-icon-button" aria-label="{{ 'action.back' | translate }}" ng-click="vm.goBack()" ng-class="{'tb-invisible': vm.displaySearchMode()}">
+				  <md-icon aria-label="{{ 'action.back' | translate }}" class="material-icons">arrow_back</md-icon>
+			  </md-button>
 	          <md-button class="md-icon-button" aria-label="{{ 'action.back' | translate }}" ng-click="searchConfig.showSearch = !searchConfig.showSearch" ng-class="{'tb-invisible': !vm.displaySearchMode()}" >
 		      	  <md-icon aria-label="{{ 'action.back' | translate }}" class="material-icons">arrow_back</md-icon>
 	          </md-button>		    
diff --git a/ui/src/app/locale/locale.constant.js b/ui/src/app/locale/locale.constant.js
index 3de229e..c7cef44 100644
--- a/ui/src/app/locale/locale.constant.js
+++ b/ui/src/app/locale/locale.constant.js
@@ -43,6 +43,8 @@ export default angular.module('thingsboard.locale', [])
                     "search": "Search",
                     "assign": "Assign",
                     "unassign": "Unassign",
+                    "share": "Share",
+                    "make-private": "Make private",
                     "apply": "Apply",
                     "apply-changes": "Apply changes",
                     "edit-mode": "Edit mode",
@@ -61,7 +63,8 @@ export default angular.module('thingsboard.locale', [])
                     "copy": "Copy",
                     "paste": "Paste",
                     "import": "Import",
-                    "export": "Export"
+                    "export": "Export",
+                    "share-via": "Share via {{provider}}"
                 },
                 "aggregation": {
                     "aggregation": "Aggregation",
@@ -154,11 +157,15 @@ export default angular.module('thingsboard.locale', [])
                     "dashboard": "Customer Dashboard",
                     "dashboards": "Customer Dashboards",
                     "devices": "Customer Devices",
+                    "public-dashboards": "Public Dashboards",
+                    "public-devices": "Public Devices",
                     "add": "Add Customer",
                     "delete": "Delete customer",
                     "manage-customer-users": "Manage customer users",
                     "manage-customer-devices": "Manage customer devices",
                     "manage-customer-dashboards": "Manage customer dashboards",
+                    "manage-public-devices": "Manage public devices",
+                    "manage-public-dashboards": "Manage public dashboards",
                     "add-customer-text": "Add new customer",
                     "no-customers-text": "No customers found",
                     "customer-details": "Customer details",
@@ -191,6 +198,8 @@ export default angular.module('thingsboard.locale', [])
                     "assign-to-customer-text": "Please select the customer to assign the dashboard(s)",
                     "assign-to-customer": "Assign to customer",
                     "unassign-from-customer": "Unassign from customer",
+                    "make-public": "Make dashboard public",
+                    "make-private": "Make dashboard private",
                     "no-dashboards-text": "No dashboards found",
                     "no-widgets": "No widgets configured",
                     "add-widget": "Add new widget",
@@ -219,6 +228,14 @@ export default angular.module('thingsboard.locale', [])
                     "unassign-dashboard": "Unassign dashboard",
                     "unassign-dashboards-title": "Are you sure you want to unassign { count, select, 1 {1 dashboard} other {# dashboards} }?",
                     "unassign-dashboards-text": "After the confirmation all selected dashboards will be unassigned and won't be accessible by the customer.",
+                    "public-dashboard-title": "Dashboard is now public",
+                    "public-dashboard-text": "Your dashboard <b>{{dashboardTitle}}</b> is now public and accessible via next public <a href='{{publicLink}}' target='_blank'>link</a>:",
+                    "public-dashboard-notice": "<b>Note:</b> Do not forget to make related devices public in order to access their data.",
+                    "make-private-dashboard-title": "Are you sure you want to make the dashboard '{{dashboardTitle}}' private?",
+                    "make-private-dashboard-text": "After the confirmation the dashboard will be made private and won't be accessible by others.",
+                    "make-private-dashboard": "Make dashboard private",
+                    "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard",
+                    "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard",
                     "select-dashboard": "Select dashboard",
                     "no-dashboards-matching": "No dashboards matching '{{dashboard}}' were found.",
                     "dashboard-required": "Dashboard is required.",
@@ -248,6 +265,9 @@ export default angular.module('thingsboard.locale', [])
                     "max-vertical-margin-message": "Only 50 is allowed as maximum vertical margin value.",
                     "display-title": "Display dashboard title",
                     "title-color": "Title color",
+                    "display-device-selection": "Display device selection",
+                    "display-dashboard-timewindow": "Display timewindow",
+                    "display-dashboard-export": "Display export",
                     "import": "Import dashboard",
                     "export": "Export dashboard",
                     "export-failed-error": "Unable to export dashboard: {{error}}",
@@ -267,7 +287,11 @@ export default angular.module('thingsboard.locale', [])
                     "invalid-aliases-config": "Unable to find any devices matching to some of the aliases filter.<br/>" +
                                               "Please contact your administrator in order to resolve this issue.",
                     "select-devices": "Select devices",
-                    "assignedToCustomer": "Assigned to customer"
+                    "assignedToCustomer": "Assigned to customer",
+                    "public": "Public",
+                    "public-link": "Public link",
+                    "copy-public-link": "Copy public link",
+                    "public-link-copied-message": "Dashboard public link has been copied to clipboard"
                 },
                 "datakey": {
                     "settings": "Settings",
@@ -323,6 +347,8 @@ export default angular.module('thingsboard.locale', [])
                     "assign-to-customer": "Assign to customer",
                     "assign-device-to-customer": "Assign Device(s) To Customer",
                     "assign-device-to-customer-text": "Please select the devices to assign to the customer",
+                    "make-public": "Make device public",
+                    "make-private": "Make device private",
                     "no-devices-text": "No devices found",
                     "assign-to-customer-text": "Please select the customer to assign the device(s)",
                     "device-details": "Device details",
@@ -337,6 +363,10 @@ export default angular.module('thingsboard.locale', [])
                     "unassign-devices": "Unassign devices",
                     "unassign-devices-action-title": "Unassign { count, select, 1 {1 device} other {# devices} } from customer",
                     "assign-new-device": "Assign new device",
+                    "make-public-device-title": "Are you sure you want to make the device '{{deviceName}}' public?",
+                    "make-public-device-text": "After the confirmation the device and all its data will be made public and accessible by others.",
+                    "make-private-device-title": "Are you sure you want to make the device '{{deviceName}}' private?",
+                    "make-private-device-text": "After the confirmation the device and all its data will be made private and won't be accessible by others.",
                     "view-credentials": "View credentials",
                     "delete-device-title": "Are you sure you want to delete the device '{{deviceName}}'?",
                     "delete-device-text": "Be careful, after the confirmation the device and all related data will become unrecoverable.",
@@ -369,7 +399,9 @@ export default angular.module('thingsboard.locale', [])
                     "assignedToCustomer": "Assigned to customer",
                     "unable-delete-device-alias-title": "Unable to delete device alias",
                     "unable-delete-device-alias-text": "Device alias '{{deviceAlias}}' can't be deleted as it used by the following widget(s):<br/>{{widgetsList}}",
-                    "is-gateway": "Is gateway"
+                    "is-gateway": "Is gateway",
+                    "public": "Public",
+                    "device-public": "Device is public"
                 },
                 "dialog": {
                     "close": "Close dialog"
@@ -778,7 +810,8 @@ export default angular.module('thingsboard.locale', [])
                 "language": {
                     "language": "Language",
                     "en_US": "English",
-                    "ko_KR": "Korean"
+                    "ko_KR": "Korean",
+                    "zh_CN": "Chinese"
                 }
             }
         }
diff --git a/ui/src/app/locale/locale.constant-ko.js b/ui/src/app/locale/locale.constant-ko.js
index bdc9388..ec194f7 100644
--- a/ui/src/app/locale/locale.constant-ko.js
+++ b/ui/src/app/locale/locale.constant-ko.js
@@ -775,7 +775,8 @@ export default function addLocaleKorean(locales) {
         "language": {
             "language": "언어",
             "en_US": "영어",
-            "ko_KR": "한글"
+            "ko_KR": "한글",
+            "zh_CN": "중국어"
         }
     };
     angular.extend(locales, {'ko_KR': ko_KR});
diff --git a/ui/src/app/locale/locale.constant-zh.js b/ui/src/app/locale/locale.constant-zh.js
new file mode 100644
index 0000000..0ded94a
--- /dev/null
+++ b/ui/src/app/locale/locale.constant-zh.js
@@ -0,0 +1,818 @@
+/*
+ * Copyright © 2016-2017 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.
+ */
+
+export default function addLocaleChinese(locales) {
+    var zh_CN = {
+        "access" : {
+            "unauthorized" : "未授权",
+            "unauthorized-access" : "未授权访问",
+            "unauthorized-access-text" : "您需要登陆才能访问这个资源!",
+            "access-forbidden" : "禁止访问",
+            "access-forbidden-text" : "您没有访问此位置的权限<br/>如果您仍希望访问此位置,请尝试使用其他用户登录。",
+            "refresh-token-expired" : "会话已过期",
+            "refresh-token-failed" : "无法刷新会话"
+        },
+        "action" : {
+            "activate" : "激活",
+            "suspend" : "暂停",
+            "save" : "保存",
+            "saveAs" : "另存为",
+            "cancel" : "取消",
+            "ok" : "确定",
+            "delete" : "删除",
+            "add" : "添加",
+            "yes" : "是",
+            "no" : "否",
+            "update" : "更新",
+            "remove" : "移除",
+            "search" : "查询",
+            "assign" : "分配",
+            "unassign" : "取消分配",
+            "share" : "分享",
+            "make-private" : "私有",
+            "apply" : "应用",
+            "apply-changes" : "应用更改",
+            "edit-mode" : "编辑模式",
+            "enter-edit-mode" : "进入编辑模式",
+            "decline-changes" : "拒绝变更",
+            "close" : "关闭",
+            "back" : "后退",
+            "run" : "运行",
+            "sign-in" : "登录!",
+            "edit" : "编辑",
+            "view" : "查看",
+            "create" : "创建",
+            "drag" : "拖拽",
+            "refresh" : "刷新",
+            "undo" : "撤销",
+            "copy" : "复制",
+            "paste" : "粘贴",
+            "import" : "导入",
+            "export" : "导出",
+            "share-via" : "通过 {{provider}}分享"
+        },
+        "aggregation" : {
+            "aggregation" : "聚合",
+            "function" : "数据聚合功能",
+            "limit" : "最大值",
+            "group-interval" : "分组间隔",
+            "min" : "最少值",
+            "max" : "最大值",
+            "avg" : "平均值",
+            "sum" : "求和",
+            "count" : "计数",
+            "none" : "空"
+        },
+        "admin" : {
+            "general" : "常规",
+            "general-settings" : "常规设置",
+            "outgoing-mail" : "发送邮件",
+            "outgoing-mail-settings" : "发送邮件设置",
+            "system-settings" : "系统设置",
+            "test-mail-sent" : "测试邮件发送成功!",
+            "base-url" : "基本URL",
+            "base-url-required" : "基本URL是必须的。",
+            "mail-from" : "邮件来自",
+            "mail-from-required" : "邮件发件人是必须的。",
+            "smtp-protocol" : "SMTP协议",
+            "smtp-host" : "SMTP主机",
+            "smtp-host-required" : "SMTP主机是必须的。",
+            "smtp-port" : "SMTP端口",
+            "smtp-port-required" : "您必须提供一个smtp端口。",
+            "smtp-port-invalid" : "这看起来不是有效的smtp端口。",
+            "timeout-msec" : "超时(ms)",
+            "timeout-required" : "超时是必须的。",
+            "timeout-invalid" : "这看起来不像有效的超时值。",
+            "enable-tls" : "启用TLS",
+            "send-test-mail" : "发送测试邮件"
+        },
+        "attribute" : {
+            "attributes" : "属性",
+            "latest-telemetry" : "最新遥测",
+            "attributes-scope" : "设备属性范围",
+            "scope-latest-telemetry" : "最新遥测",
+            "scope-client" : "客户端属性",
+            "scope-server" : "服务端属性",
+            "scope-shared" : "共享属性",
+            "add" : "添加属性",
+            "key" : "键",
+            "key-required" : "属性键是必需的。",
+            "value" : "值",
+            "value-required" : "属性值是必需的。",
+            "delete-attributes-title" : "您确定要删除 { count, select, 1 {1 attribute} other {# attributes} }吗?",
+            "delete-attributes-text" : "注意,确认后所有选中的属性都会被删除。",
+            "delete-attributes" : "删除属性",
+            "enter-attribute-value" : "输入属性值",
+            "show-on-widget" : "在小部件上显示",
+            "widget-mode" : "小部件模式",
+            "next-widget" : "下一个小部件",
+            "prev-widget" : "上一个小部件",
+            "add-to-dashboard" : "添加到仪表板",
+            "add-widget-to-dashboard" : "将小部件添加到仪表板",
+            "selected-attributes" : "{ count, select, 1 {1 attribute} other {# attributes} } 被选中",
+            "selected-telemetry" : "{ count, select, 1 {1 telemetry unit} other {# telemetry units} } 被选中"
+        },
+        "confirm-on-exit" : {
+            "message" : "您有未保存的更改。确定要离开此页吗?",
+            "html-message" : "您有未保存的更改。<br/> 确定要离开此页面吗?",
+            "title" : "未保存的更改"
+        },
+        "contact" : {
+            "country" : "国家",
+            "city" : "城市",
+            "state" : "州",
+            "postal-code" : "邮政编码",
+            "postal-code-invalid" : "只允许数字。",
+            "address" : "地址",
+            "address2" : "地址2",
+            "phone" : "手机",
+            "email" : "邮箱",
+            "no-address" : "无地址"
+        },
+        "common" : {
+            "username" : "用户名",
+            "password" : "密码",
+            "enter-username" : "输入用户名",
+            "enter-password" : "输入密码",
+            "enter-search" : "输入检索条件"
+        },
+        "customer" : {
+            "customers" : "客户",
+            "management" : "客户管理",
+            "dashboard" : "客户仪表板",
+            "dashboards" : "客户仪表板",
+            "devices" : "客户设备",
+            "public-dashboards" : "公共仪表板",
+            "public-devices" : "公共设备",
+            "add" : "添加客户",
+            "delete" : "删除客户",
+            "manage-customer-users" : "管理客户用户",
+            "manage-customer-devices" : "管理客户设备",
+            "manage-customer-dashboards" : "管理客户仪表板",
+            "manage-public-devices" : "管理公共设备",
+            "manage-public-dashboards" : "管理公共仪表板",
+            "add-customer-text" : "添加新客户",
+            "no-customers-text" : "没有找到客户",
+            "customer-details" : "客户详情",
+            "delete-customer-title" : "您确定要删除客户'{{customerTitle}}'吗?",
+            "delete-customer-text" : "小心!确认后,客户及其所有相关数据将不可恢复。",
+            "delete-customers-title" : "您确定要删除 { count, select, 1 {1 customer} other {# customers} }?吗",
+            "delete-customers-action-title" : "删除 { count, select, 1 {1 customer} other {# customers} }",
+            "delete-customers-text" : "小心!确认后,所有选定的客户将被删除,所有相关数据将不可恢复。",
+            "manage-users" : "管理用户",
+            "manage-devices" : "管理设备",
+            "manage-dashboards" : "管理仪表板",
+            "title" : "标题",
+            "title-required" : "需要标题。",
+            "description" : "描述"
+        },
+        "datetime" : {
+            "date-from" : "日期从",
+            "time-from" : "时间从",
+            "date-to" : "日期到",
+            "time-to" : "时间到"
+        },
+        "dashboard" : {
+            "dashboard" : "仪表板",
+            "dashboards" : "仪表板库",
+            "management" : "仪表板管理",
+            "view-dashboards" : "查看仪表板",
+            "add" : "添加仪表板",
+            "assign-dashboard-to-customer" : "将仪表板分配给客户",
+            "assign-dashboard-to-customer-text" : "请选择要分配给客户的仪表板",
+            "assign-to-customer-text" : "请选择客户分配仪表板",
+            "assign-to-customer" : "分配给客户",
+            "unassign-from-customer" : "取消分配客户",
+            "make-public" : "使仪表板公有",
+            "make-private" : "使仪表板私有",
+            "no-dashboards-text" : "没有找到仪表板",
+            "no-widgets" : "没有配置小部件",
+            "add-widget" : "添加新的小部件",
+            "title" : "标题",
+            "select-widget-title" : "选择小部件",
+            "select-widget-subtitle" : "可用的小部件类型列表",
+            "delete" : "删除仪表板",
+            "title-required" : "需要标题。",
+            "description" : "描述",
+            "details" : "详情",
+            "dashboard-details" : "仪表板详情",
+            "add-dashboard-text" : "添加新的仪表板",
+            "assign-dashboards" : "分配仪表板",
+            "assign-new-dashboard" : "分配新的仪表板",
+            "assign-dashboards-text" : "分配 { count, select, 1 {1 dashboard} other {# dashboards} } 给客户",
+            "delete-dashboards" : "删除仪表板",
+            "unassign-dashboards" : "取消分配仪表板",
+            "unassign-dashboards-action-title" : "取消分配 { count, select, 1 {1 dashboard} other {# dashboards} } from customer",
+            "delete-dashboard-title" : "您确定要删除仪表板 '{{dashboardTitle}}'吗?",
+            "delete-dashboard-text" : "小心!确认后仪表板及其所有相关数据将不可恢复。",
+            "delete-dashboards-title" : "你确定你要删除 { count, select, 1 {1 dashboard} other {# dashboards} }吗?",
+            "delete-dashboards-action-title" : "删除 { count, select, 1 {1 dashboard} other {# dashboards} }",
+            "delete-dashboards-text" : "小心!确认后所有选定的仪表板将被删除,所有相关数据将不可恢复。",
+            "unassign-dashboard-title" : "您确定要取消分配仪表板 '{{dashboardTitle}}'吗?",
+            "unassign-dashboard-text" : "确认后,面板将被取消分配,客户将无法访问。",
+            "unassign-dashboard" : "取消分配仪表板",
+            "unassign-dashboards-title" : "您确定要取消分配仪表板 { count, select, 1 {1 dashboard} other {# dashboards} } 吗?",
+            "unassign-dashboards-text" : "确认后,所有选定的仪表板将被取消分配,客户将无法访问。",
+            "public-dashboard-title" : "仪表板现已公布",
+            "public-dashboard-text" : "你的仪表板 <b>{{dashboardTitle}}</b> 已被公开,可通过如下 <a href='{{publicLink}}' target='_blank'>链接</a>访问:",
+            "public-dashboard-notice" : "<b>提示:</b> 不要忘记将相关设备公开以访问其数据。",
+            "make-private-dashboard-title" : "您确定要使仪表板 '{{dashboardTitle}}' 私有吗?",
+            "make-private-dashboard-text" : "确认后,仪表板将被私有,不能被其他人访问。",
+            "make-private-dashboard" : "仪表板私有",
+            "socialshare-text" : "'{{dashboardTitle}}' 由ThingsBoard提供支持",
+            "socialshare-title" : "'{{dashboardTitle}}' 由ThingsBoard提供支持",
+            "select-dashboard" : "选择仪表板",
+            "no-dashboards-matching" : "找不到符合 '{{dashboard}}' 的仪表板。",
+            "dashboard-required" : "仪表板是必需的。",
+            "select-existing" : "选择现有仪表板",
+            "create-new" : "创建新的仪表板",
+            "new-dashboard-title" : "新仪表板标题",
+            "open-dashboard" : "打开仪表板",
+            "set-background" : "设置背景",
+            "background-color" : "背景颜色",
+            "background-image" : "背景图片",
+            "background-size-mode" : "背景大小模式",
+            "no-image" : "无图像选择",
+            "drop-image" : "拖拽图像或单击以选择要上传的文件。",
+            "settings" : "设置",
+            "columns-count" : "列数",
+            "columns-count-required" : "需要列数。",
+            "min-columns-count-message" : "只允许最少10列",
+            "max-columns-count-message" : "只允许最多1000列",
+            "widgets-margins" : "部件间边距",
+            "horizontal-margin" : "水平边距",
+            "horizontal-margin-required" : "需要水平边距值。",
+            "min-horizontal-margin-message" : "只允许0作为最小水平边距值。",
+            "max-horizontal-margin-message" : "只允许50作为最大水平边距值。",
+            "vertical-margin" : "垂直边距",
+            "vertical-margin-required" : "需要垂直边距值。",
+            "min-vertical-margin-message" : "只允许0作为最小垂直边距值。",
+            "max-vertical-margin-message" : "只允许50作为最大垂直边距值。",
+            "display-title" : "显示仪表板标题",
+            "title-color" : "标题颜色",
+            "display-device-selection" : "显示设备选择",
+            "display-dashboard-timewindow" : "显示时间窗口",
+            "display-dashboard-export" : "显示导出",
+            "import" : "导入仪表板",
+            "export" : "导出仪表板",
+            "export-failed-error" : "无法导出仪表板: {{error}}",
+            "create-new-dashboard" : "创建新的仪表板",
+            "dashboard-file" : "仪表板文件",
+            "invalid-dashboard-file-error" : "无法导入仪表板: 仪表板数据结构无效。",
+            "dashboard-import-missing-aliases-title" : "配置导入仪表板使用的别名",
+            "create-new-widget" : "创建新小部件",
+            "import-widget" : "导入小部件",
+            "widget-file" : "小部件文件",
+            "invalid-widget-file-error" : "无法导入窗口小部件: 窗口小部件数据结构无效。",
+            "widget-import-missing-aliases-title" : "配置导入的窗口小部件使用的别名",
+            "open-toolbar" : "打开仪表板工具栏",
+            "close-toolbar" : "关闭工具栏",
+            "configuration-error" : "配置错误",
+            "alias-resolution-error-title" : "仪表板别名配置错误",
+            "invalid-aliases-config" : "无法找到与某些别名过滤器匹配的任何设备。<br/>" +
+                "请联系您的管理员以解决此问题。",
+            "select-devices" : "选择设备",
+            "assignedToCustomer" : "分配给客户",
+            "public" : "公共",
+            "public-link" : "公共链接",
+            "copy-public-link" : "复制公共链接",
+            "public-link-copied-message" : "仪表板的公共链接已被复制到剪贴板"
+        },
+        "datakey" : {
+            "settings": "设置",
+            "advanced": "高级",
+            "label": "标签",
+            "color": "颜色",
+            "data-generation-func": "数据生成功能",
+            "use-data-post-processing-func": "使用数据后处理功能",
+            "configuration": "数据键配置",
+            "timeseries": "时间序列",
+            "attributes": "属性",
+            "timeseries-required": "需要设备时间序列。",
+            "timeseries-or-attributes-required": "设备时间/属性是必需的。",
+            "function-types": "函数类型",
+            "function-types-required": "需要函数类型。"
+        },
+        "datasource" : {
+            "type": "数据源类型",
+            "add-datasource-prompt": "请添加数据源"
+        },
+        "details" : {
+            "edit-mode": "编辑模式",
+            "toggle-edit-mode": "切换编辑模式"
+        },
+        "device" : {
+            "device": "设备",
+            "device-required": "设备是必需的",
+            "devices": "设备",
+            "management": "设备管理",
+            "view-devices": "查看设备",
+            "device-alias": "设备别名",
+            "aliases": "设备别名",
+            "no-alias-matching" : "'{{alias}}' 没有找到。",
+            "no-aliases-found" : "找不到别名。",
+            "no-key-matching" : "'{{key}}' 没有找到。",
+            "no-keys-found" : "找不到密钥。",
+            "create-new-alias": "创建一个新的!",
+            "create-new-key": "创建一个新的!",
+            "duplicate-alias-error" : "找到重复别名 '{{alias}}'。 <br> 设备别名必须是唯一的。",
+            "configure-alias" : "配置 '{{alias}}' 别名",
+            "no-devices-matching" : "找不到与 '{{device}}' 匹配的设备。",
+            "alias" : "别名",
+            "alias-required" : "需要设备别名。",
+            "remove-alias": "删除设备别名",
+            "add-alias": "添加设备别名",
+            "name-starts-with" : "名称前缀",
+            "device-list" : "设备列表",
+            "use-device-name-filter" : "使用过滤器",
+            "device-list-empty" : "没有被选中的设备",
+            "device-name-filter-required" : "设备名称过滤器是必需得。",
+            "device-name-filter-no-device-matched" : "找不到以'{{device}}' 开头的设备。",
+            "add" : "添加设备",
+            "assign-to-customer": "分配给客户",
+            "assign-device-to-customer": "将设备分配给客户",
+            "assign-device-to-customer-text": "请选择要分配给客户的设备",
+            "make-public" : "公有",
+            "make-private" : "私有",
+            "no-devices-text": "找不到设备",
+            "assign-to-customer-text": "请选择客户分配设备",
+            "device-details": "设备详细信息",
+            "add-device-text": "添加新设备",
+            "credentials": "凭据",
+            "manage-credentials": "管理凭据",
+            "delete": "删除设备",
+            "assign-devices": "分配设备",
+            "assign-devices-text": "将{count,select,1 {1 device} other {# devices}}分配给客户",
+            "delete-devices": "删除设备",
+            "unassign-from-customer": "取消分配客户",
+            "unassign-devices": "取消分配设备",
+            "unassign-devices-action-title": "从客户处取消分配{count,select,1 {1 device} other {# devices}}",
+            "assign-new-device": "分配新设备",
+            "make-public-device-title" : "您确定要将设备 '{{deviceName}}' 设为公开吗?",
+            "make-public-device-text" : "确认后,设备及其所有数据将被公开并可被其他人访问。",
+            "make-private-device-title" : "您确定要将设备 '{{deviceName}}' 设为私有吗?",
+            "make-private-device-text" : "确认后,设备及其所有数据将被私有化,不被其他人访问。",
+            "view-credentials": "查看凭据",
+            "delete-device-title": "您确定要删除设备的{{deviceName}}吗?",
+            "delete-device-text": "小心!确认后设备及其所有相关数据将不可恢复。",
+            "delete-devices-title": "您确定要删除{count,select,1 {1 device} other {# devices}} 吗?",
+            "delete-devices-action-title": "删除 {count,select,1 {1 device} other {# devices}}",
+            "delete-devices-text": "小心!确认后所有选定的设备将被删除,所有相关数据将不可恢复。",
+            "unassign-device-title": "您确定要取消分配设备 '{{deviceName}}'?",
+            "unassign-device-text": "确认后,设备将被取消分配,客户将无法访问。",
+            "unassign-device": "取消分配设备",
+            "unassign-devices-title": "您确定要取消分配{count,select,1 {1 device} other {# devices}} 吗?",
+            "unassign-devices-text": "确认后,所有选定的设备将被取消分配,并且客户将无法访问。",
+            "device-credentials": "设备凭据",
+            "credentials-type": "凭据类型",
+            "access-token": "访问令牌",
+            "access-token-required": "需要访问令牌",
+            "access-token-invalid": "访问令牌长度必须为1到20个字符。",
+            "rsa-key": "RSA公钥",
+            "rsa-key-required": "需要RSA公钥",
+            "secret": "密钥",
+            "secret-required": "密钥是必需的",
+            "name": "名称",
+            "name-required": "名称是必需的。",
+            "description": "说明",
+            "events": "事件",
+            "details": "详细信息",
+            "copyId": "复制设备ID",
+            "copyAccessToken": "复制访问令牌",
+            "idCopiedMessage": "设备ID已复制到剪贴板",
+            "accessTokenCopiedMessage": "设备访问令牌已复制到剪贴板",
+            "assignedToCustomer": "分配给客户",
+            "unable-delete-device-alias-title": "无法删除设备别名",
+            "unable-delete-device-alias-text": "设备别名 '{{deviceAlias}}' 不能够被删除,因为它被下列部件所使用: <br/> {{widgetsList}}",
+            "is-gateway": "是网关",
+            "public" : "公共",
+            "device-public" : "设备是公共的"
+        },
+        "dialog" : {
+            "close" : "关闭对话框"
+        },
+        "error" : {
+            "unable-to-connect": "无法连接到服务器!请检查您的互联网连接。",
+            "unhandled-error-code": "未处理的错误代码: {{errorCode}}",
+            "unknown-error": "未知错误"
+        },
+        "event" : {
+            "event-type": "事件类型",
+            "type-alarm": "报警",
+            "type-error": "错误",
+            "type-lc-event": "生命周期事件",
+            "type-stats": "类型统计",
+            "no-events-prompt": "找不到事件",
+            "error": "错误",
+            "alarm": "报警",
+            "event-time": "事件时间",
+            "server": "服务器",
+            "body": "整体",
+            "method": "方法",
+            "event": "事件",
+            "status": "状态",
+            "success": "成功",
+            "failed": "失败",
+            "messages-processed": "消息处理",
+            "errors-occurred": "错误发生"
+        },
+        "fullscreen" : {
+            "expand": "展开到全屏",
+            "exit": "退出全屏",
+            "toggle": "切换全屏模式",
+            "fullscreen": "全屏"
+        },
+        "function" : {
+            "function" : "函数"
+        },
+        "grid" : {
+            "delete-item-title": "您确定要删除此项吗?",
+            "delete-item-text": "注意,确认后此项及其所有相关数据将变得不可恢复。",
+            "delete-items-title" : "你确定你要删除 { count, select, 1 {1 item} other {# items} }吗?",
+            "delete-items-action-title" : "删除 { count, select, 1 {1 item} other {# items} }",
+            "delete-items-text": "注意,确认后所有选择的项目将被删除,所有相关数据将不可恢复。",
+            "add-item-text": "添加新项目",
+            "no-items-text": "没有找到项目",
+            "item-details": "项目详细信息",
+            "delete-item": "删除项目",
+            "delete-items": "删除项目",
+            "scroll-to-top": "滚动到顶部"
+        },
+        "help" : {
+            "goto-help-page" : "转到帮助页面"
+        },
+        "home" : {
+            "home": "首页",
+            "profile": "属性",
+            "logout": "注销",
+            "menu": "菜单",
+            "avatar": "头像",
+            "open-user-menu": "打开用户菜单"
+        },
+        "import" : {
+            "no-file" : "没有选择文件",
+            "drop-file" : "拖动一个JSON文件或者单击以选择要上传的文件。"
+        },
+        "item" : {
+            "selected" : "选择"
+        },
+        "js-func" : {
+            "no-return-error": "函数必须返回值!",
+            "return-type-mismatch": "函数必须返回 '{{type}}' 类型的值!"
+        },
+        "legend" : {
+            "position" : "图例位置",
+            "show-max" : "显示最大值",
+            "show-min" : "显示最小值",
+            "show-avg" : "显示平均值",
+            "show-total" : "显示总数",
+            "settings" : "图例设置",
+            "min" : "最小值",
+            "max" : "最大值",
+            "avg" : "平均值",
+            "total" : "总数"
+        },
+        "login" : {
+            "login": "登录",
+            "request-password-reset": "请求密码重置",
+            "reset-password": "重置密码",
+            "create-password": "创建密码",
+            "passwords-mismatch-error": "输入的密码必须相同!",
+            "password-again": "再次输入密码",
+            "sign-in": "登录 ",
+            "username": "用户名(电子邮件)",
+            "remember-me": "记住我",
+            "forgot-password": "忘记密码?",
+            "password-reset": "密码重置",
+            "new-password": "新密码",
+            "new-password-again": "再次输入新密码",
+            "password-link-sent-message": "密码重置链接已成功发送!",
+            "email": "电子邮件"
+        },
+        "plugin" : {
+            "plugins" : "插件",
+            "delete" : "删除插件",
+            "activate" : "激活插件",
+            "suspend" : "暂停插件",
+            "active" : "激活",
+            "suspended" : "暂停",
+            "name" : "名称",
+            "name-required" : "名称是必填项。",
+            "description" : "描述",
+            "add" : "添加插件",
+            "delete-plugin-title" : "你确定要删除插件 '{{pluginName}}' 吗?",
+            "delete-plugin-text" : "小心!确认后,插件和所有相关数据将不可恢复。",
+            "delete-plugins-title" : "你确定你要删除 { count, select, 1 {1 plugin} other {# plugins} } 吗?",
+            "delete-plugins-action-title" : "删除 { count, select, 1 {1 plugin} other {# plugins} }",
+            "delete-plugins-text" : "小心!确认后,所有选定的插件将被删除,所有相关数据将不可恢复。",
+            "add-plugin-text" : "添加新的插件",
+            "no-plugins-text" : "没有找到插件",
+            "plugin-details" : "插件详细信息",
+            "api-token" : "API令牌",
+            "api-token-required" : "API令牌是必需的。",
+            "type" : "插件类型",
+            "type-required" : "插件类型是必需的。",
+            "configuration" : "插件配置",
+            "system" : "系统",
+            "select-plugin" : "选择插件",
+            "plugin" : "插件",
+            "no-plugins-matching" : "没有找到匹配'{{plugin}}'的插件。",
+            "plugin-required" : "插件是必需的。",
+            "plugin-require-match" : "请选择一个现有的插件。",
+            "events" : "事件",
+            "details" : "详情",
+            "import" : "导入插件",
+            "export" : "导出插件",
+            "export-failed-error" : "无法导出插件:{{error}}",
+            "create-new-plugin" : "创建新的插件",
+            "plugin-file" : "插件文件",
+            "invalid-plugin-file-error" : "无法导入插件:插件数据结构无效。"
+        },
+        "position" : {
+            "top" : "顶部",
+            "bottom" : "底部",
+            "left" : "左侧",
+            "right" : "右侧"
+        },
+        "profile" : {
+            "profile": "属性",
+            "change-password": "更改密码",
+            "current-password": "当前密码"
+        },
+        "rule" : {
+            "rules" : "规则",
+            "delete" : "删除规则",
+            "activate" : "激活规则",
+            "suspend" : "暂停规则",
+            "active" : "激活",
+            "suspended" : "暂停",
+            "name" : "名称",
+            "name-required" : "名称是必填项。",
+            "description" : "描述",
+            "add" : "添加规则",
+            "delete-rule-title" : "您确定要删除规则'{{ruleName}}'吗?",
+            "delete-rule-text" : "小心!确认后,规则和所有相关数据将不可恢复。",
+            "delete-rules-title" : "你确定要删除 {count, select, 1 {1 rule} other {# rules}} 吗?",
+            "delete-rules-action-title" : "删除 { count, select, 1 {1 rule} other {# rules} }",
+            "delete-rules-text" : "小心!确认后,所有选定的规则将被删除,所有相关数据将不可恢复。",
+            "add-rule-text" : "添加新规则",
+            "no-rules-text" : "没有找到规则",
+            "rule-details" : "规则详情",
+            "filters" : "过滤器",
+            "filter" : "过滤器",
+            "add-filter-prompt" : "请添加过滤器",
+            "remove-filter" : "删除过滤器",
+            "add-filter" : "添加过滤器",
+            "filter-name" : "过滤器名称",
+            "filter-type" : "过滤器类型",
+            "edit-filter" : "编辑过滤器",
+            "view-filter" : "查看过滤器",
+            "component-name" : "名称",
+            "component-name-required" : "名称是必填项。",
+            "component-type" : "类型",
+            "component-type-required" : "类型是必填项。",
+            "processor" : "处理器",
+            "no-processor-configured" : "未配置处理器",
+            "create-processor" : "创建处理器",
+            "processor-name" : "处理器名称",
+            "processor-type" : "处理器类型",
+            "plugin-action" : "插件动作",
+            "action-name" : "动作名称",
+            "action-type" : "动作类型",
+            "create-action-prompt" : "请创建动作",
+            "create-action" : "创建动作",
+            "details" : "详情",
+            "events" : "事件",
+            "system" : "系统",
+            "import" : "导入规则",
+            "export" : "导出规则",
+            "export-failed-error" : "无法导出规则:{{error}}",
+            "create-new-rule" : "创建新规则",
+            "rule-file" : "规则文件",
+            "invalid-rule-file-error" : "无法导入规则:规则数据结构无效。"
+        },
+        "rule-plugin" : {
+            "management" : "规则和插件管理"
+        },
+        "tenant" : {
+            "tenants" : "租户",
+            "management" : "租户管理",
+            "add" : "添加租户",
+            "admins" : "管理员",
+            "manage-tenant-admins" : "管理租户管理员",
+            "delete" : "删除租户",
+            "add-tenant-text" : "添加新租户",
+            "no-tenants-text" : "没有找到租户",
+            "tenant-details" : "租客详情",
+            "delete-tenant-title" : "您确定要删除租户'{{tenantTitle}}'吗?",
+            "delete-tenant-text" : "小心!确认后,租户和所有相关数据将不可恢复。",
+            "delete-tenants-title" : "您确定要删除 {count,select,1 {1 tenant} other {# tenants}} 吗?",
+            "delete-tenants-action-title" : "删除 { count, select, 1 {1 tenant} other {# tenants} }",
+            "delete-tenants-text" : "小心!确认后,所有选定的租户将被删除,所有相关数据将不可恢复。",
+            "title" : "标题",
+            "title-required" : "标题是必填项。",
+            "description" : "描述"
+        },
+        "timeinterval" : {
+            "seconds-interval" : "{ seconds, select, 1 {1 second} other {# seconds} }",
+            "minutes-interval" : "{ minutes, select, 1 {1 minute} other {# minutes} }",
+            "hours-interval" : "{ hours, select, 1 {1 hour} other {# hours} }",
+            "days-interval" : "{ days, select, 1 {1 day} other {# days} }",
+            "days" : "天",
+            "hours" : "时",
+            "minutes" : "分",
+            "seconds" : "秒",
+            "advanced" : "高级"
+        },
+        "timewindow" : {
+            "days" : "{ days, select, 1 { day } other {# days } }",
+            "hours" : "{ hours, select, 0 { hour } 1 {1 hour } other {# hours } }",
+            "minutes" : "{ minutes, select, 0 { minute } 1 {1 minute } other {# minutes } }",
+            "seconds" : "{ seconds, select, 0 { second } 1 {1 second } other {# seconds } }",
+            "realtime" : "实时",
+            "history" : "历史",
+            "last-prefix" : "最后",
+            "period" : "从 {{ startTime }} 到 {{ endTime }}",
+            "edit" : "编辑时间窗口",
+            "date-range" : "日期范围",
+            "last" : "最后",
+            "time-period" : "时间段"
+        },
+        "user" : {
+            "users" : "用户",
+            "customer-users" : "客户用户",
+            "tenant-admins" : "租户管理员",
+            "sys-admin" : "系统管理员",
+            "tenant-admin" : "租户管理员",
+            "customer" : "客户",
+            "anonymous" : "匿名",
+            "add" : "添加用户",
+            "delete" : "删除用户",
+            "add-user-text" : "添加新用户",
+            "no-users-text" : "找不到用户",
+            "user-details" : "用户详细信息",
+            "delete-user-title" : "您确定要删除用户 '{{userEmail}}' 吗?",
+            "delete-user-text" : "小心!确认后,用户和所有相关数据将不可恢复。",
+            "delete-users-title" : "你确定你要删除 { count, select, 1 {1 user} other {# users} } 吗?",
+            "delete-users-action-title" : "删除  { count, select, 1 {1 user} other {# users} }",
+            "delete-users-text" : "小心!确认后,所有选定的用户将被删除,所有相关数据将不可恢复。",
+            "activation-email-sent-message" : "激活电子邮件已成功发送!",
+            "resend-activation" : "重新发送激活",
+            "email" : "电子邮件",
+            "email-required" : "电子邮件是必需的。",
+            "first-name" : "名字",
+            "last-name" : "姓",
+            "description" : "描述",
+            "default-dashboard" : "默认面板",
+            "always-fullscreen" : "始终全屏"
+        },
+        "value" : {
+            "type" : "值类型",
+            "string" : "字符串",
+            "string-value" : "字符串值",
+            "integer" : "数字",
+            "integer-value" : "数字值",
+            "invalid-integer-value" : "整数值无效",
+            "double" : "双精度小数",
+            "double-value" : "双精度小数值",
+            "boolean" : "布尔",
+            "boolean-value" : "布尔值",
+            "false" : "假",
+            "true" : "真"
+        },
+        "widget" : {
+            "widget-library" : "小部件库",
+            "widget-bundle" : "小部件包",
+            "select-widgets-bundle" : "选择小部件包",
+            "management" : "小部件管理",
+            "editor" : "小部件编辑器",
+            "widget-type-not-found" : "加载小部件配置时出现问题。<br> 可能关联的\n 小部件类型已删除。",
+            "widget-type-load-error" : "由于以下错误,小工具未加载:",
+            "remove" : "删除小部件",
+            "edit" : "编辑小部件",
+            "remove-widget-title" : "您确定要删除小部件 '{{widgetTitle}}' 吗?",
+            "remove-widget-text" : "确认后,窗口小部件和所有相关数据将不可恢复。",
+            "timeseries" : "时间序列",
+            "latest-values" : "最新值",
+            "rpc" : "控件小部件",
+            "static" : "静态小部件",
+            "select-widget-type" : "选择窗口小部件类型",
+            "missing-widget-title-error" : "小部件标题必须指定!",
+            "widget-saved" : "小部件已保存",
+            "unable-to-save-widget-error" : "无法保存窗口小部件! 小部件有错误!",
+            "save" : "保存小部件",
+            "saveAs" : "将小部件另存为",
+            "save-widget-type-as" : "将小部件类型另存为",
+            "save-widget-type-as-text" : "请输入新的小部件标题和/或选择目标小部件包",
+            "toggle-fullscreen" : "切换全屏",
+            "run" : "运行小部件",
+            "title" : "小部件标题",
+            "title-required" : "需要小部件标题。",
+            "type" : "小部件类型",
+            "resources" : "资源",
+            "resource-url" : "JavaScript/CSS URL",
+            "remove-resource" : "删除资源",
+            "add-resource" : "添加资源",
+            "html" : "HTML",
+            "tidy" : "整理",
+            "css" : "CSS",
+            "settings-schema" : "设置模式",
+            "datakey-settings-schema" : "数据键设置模式",
+            "javascript" : "Javascript",
+            "remove-widget-type-title" : "您确定要删除小部件类型 '{{widgetName}}'吗?",
+            "remove-widget-type-text" : "确认后,窗口小部件类型和所有相关数据将不可恢复。",
+            "remove-widget-type" : "删除小部件类型",
+            "add-widget-type" : "添加新的小部件类型",
+            "widget-type-load-failed-error" : "无法加载小部件类型!",
+            "widget-template-load-failed-error" : "无法加载小部件模板!",
+            "add" : "添加小部件",
+            "undo" : "撤消小部件更改",
+            "export" : "导出小部件"
+        },
+        "widgets-bundle" : {
+            "current" : "当前包",
+            "widgets-bundles" : "小部件包",
+            "add" : "添加小部件包",
+            "delete" : "删除小部件包",
+            "title" : "标题",
+            "title-required" : "标题是必填项。",
+            "add-widgets-bundle-text" : "添加新的小部件包",
+            "no-widgets-bundles-text" : "找不到小部件包",
+            "empty" : "小部件包是空的",
+            "details" : "详情",
+            "widgets-bundle-details" : "小部件包详细信息",
+            "delete-widgets-bundle-title" : "您确定要删除小部件包 '{{widgetsBundleTitle}}'吗?",
+            "delete-widgets-bundle-text" : "小心!确认后,小部件包和所有相关数据将不可恢复。",
+            "delete-widgets-bundles-title" : "你确定你要删除 { count, select, 1 {1 widgets bundle} other {# widgets bundles} } 吗?",
+            "delete-widgets-bundles-action-title" : "删除  { count, select, 1 {1 widgets bundle} other {# widgets bundles} }",
+            "delete-widgets-bundles-text" : "小心!确认后,所有选定的小部件包将被删除,所有相关数据将不可恢复。",
+            "no-widgets-bundles-matching" : "没有找到与 '{{widgetsBundle}}' 匹配的小部件包。",
+            "widgets-bundle-required" : "需要小部件包。",
+            "system" : "系统",
+            "import" : "导入小部件包",
+            "export" : "导出小部件包",
+            "export-failed-error" : "无法导出小部件包: {{error}}",
+            "create-new-widgets-bundle" : "创建新的小部件包",
+            "widgets-bundle-file" : "小部件包文件",
+            "invalid-widgets-bundle-file-error" : "无法导入小部件包:无效的小部件包数据结构。"
+        },
+        "widget-config" : {
+            "data" : "数据",
+            "settings" : "设置",
+            "advanced" : "高级",
+            "title" : "标题",
+            "general-settings" : "常规设置",
+            "display-title" : "显示标题",
+            "drop-shadow" : "阴影",
+            "enable-fullscreen" : "启用全屏",
+            "background-color" : "背景颜色",
+            "text-color" : "文字颜色",
+            "padding" : "填充",
+            "title-style" : "标题风格",
+            "mobile-mode-settings" : "移动模式设置",
+            "order" : "顺序",
+            "height" : "高度",
+            "units" : "特殊符号展示值",
+            "decimals" : "浮点数后的位数",
+            "timewindow" : "时间窗口",
+            "use-dashboard-timewindow" : "使用仪表板的时间窗口",
+            "display-legend" : "显示图例",
+            "datasources" : "数据源",
+            "datasource-type" : "类型",
+            "datasource-parameters" : "参数",
+            "remove-datasource" : "移除数据源",
+            "add-datasource" : "添加数据源",
+            "target-device" : "目标设备"
+        },
+        "widget-type" : {
+            "import" : "导入小部件类型",
+            "export" : "导出小部件类型",
+            "export-failed-error" : "无法导出小部件类型: {{error}}",
+            "create-new-widget-type" : "创建新的小部件类型",
+            "widget-type-file" : "小部件类型文件",
+            "invalid-widget-type-file-error" : "无法导入小部件类型:无效的小部件类型数据结构。"
+        },
+        "language" : {
+            "language" : "语言",
+            "en_US" : "英语",
+            "ko_KR" : "韩语",
+            "zh_CN" : "汉语"
+        }
+    };
+    angular.extend(locales, {
+        'zh_CN' : zh_CN
+    });
+}
\ No newline at end of file
diff --git a/ui/src/app/profile/profile.controller.js b/ui/src/app/profile/profile.controller.js
index 19a16da..0e0b5ac 100644
--- a/ui/src/app/profile/profile.controller.js
+++ b/ui/src/app/profile/profile.controller.js
@@ -27,7 +27,11 @@ export default function ProfileController(userService, $scope, $document, $mdDia
 
     vm.save = save;
     vm.changePassword = changePassword;
-    vm.languageList = {en_US: {value: "en_US", name: "language.en_US"}, ko_KR: {value : "ko_KR", name: "language.ko_KR"}};
+    vm.languageList = {
+        en_US: {value : "en_US", name: "language.en_US"}, 
+        ko_KR: {value : "ko_KR", name: "language.ko_KR"},
+        zh_CN: {value : "zh_CN", name: "language.zh_CN"}
+    };
 
     loadProfile();
 
diff --git a/ui/src/app/widget/lib/flot-widget.js b/ui/src/app/widget/lib/flot-widget.js
index 446881d..af70de1 100644
--- a/ui/src/app/widget/lib/flot-widget.js
+++ b/ui/src/app/widget/lib/flot-widget.js
@@ -24,6 +24,7 @@ import 'flot/src/plugins/jquery.flot.selection';
 import 'flot/src/plugins/jquery.flot.pie';
 import 'flot/src/plugins/jquery.flot.crosshair';
 import 'flot/src/plugins/jquery.flot.stack';
+import 'flot.curvedlines/curvedLines';
 
 /* eslint-disable angular/angularelement */
 export default class TbFlot {
@@ -31,34 +32,8 @@ export default class TbFlot {
 
         this.ctx = ctx;
         this.chartType = chartType || 'line';
+        var settings = ctx.settings;
 
-        var colors = [];
-        for (var i = 0; i < ctx.data.length; i++) {
-            var series = ctx.data[i];
-            colors.push(series.dataKey.color);
-            var keySettings = series.dataKey.settings;
-
-            series.lines = {
-                fill: keySettings.fillLines === true,
-                show: this.chartType === 'line' ? keySettings.showLines !== false : keySettings.showLines === true
-            };
-
-            series.points = {
-                show: false,
-                radius: 8
-            };
-            if (keySettings.showPoints === true) {
-                series.points.show = true;
-                series.points.lineWidth = 5;
-                series.points.radius = 3;
-            }
-
-            var lineColor = tinycolor(series.dataKey.color);
-            lineColor.setAlpha(.75);
-
-            series.highlightColor = lineColor.toRgbString();
-
-        }
         ctx.tooltip = $('#flot-series-tooltip');
         if (ctx.tooltip.length === 0) {
             ctx.tooltip = $("<div id='flot-series-tooltip' class='flot-mouse-value'></div>");
@@ -160,7 +135,6 @@ export default class TbFlot {
             };
         }
 
-        var settings = ctx.settings;
         ctx.trackDecimals = angular.isDefined(settings.decimals) ?
             settings.decimals : ctx.decimals;
 
@@ -176,7 +150,6 @@ export default class TbFlot {
         };
 
         var options = {
-            colors: colors,
             title: null,
             subtitle: null,
             shadowSize: settings.shadowSize || 4,
@@ -268,6 +241,13 @@ export default class TbFlot {
                 stack: settings.stack === true
             }
 
+            if (this.chartType === 'line' && settings.smoothLines) {
+                options.series.curvedLines = {
+                    active: true,
+                    monotonicFit: true
+                }
+            }
+
             if (this.chartType === 'bar') {
                 options.series.lines = {
                         show: false,
@@ -276,14 +256,10 @@ export default class TbFlot {
                 }
                 options.series.bars ={
                         show: true,
-                        barWidth: ctx.timeWindow.interval * 0.6,
                         lineWidth: 0,
                         fill: 0.9
                 }
             }
-
-            options.xaxis.min = ctx.timeWindow.minTime;
-            options.xaxis.max = ctx.timeWindow.maxTime;
         } else if (this.chartType === 'pie') {
             options.series = {
                 pie: {
@@ -326,55 +302,121 @@ export default class TbFlot {
 
         this.options = options;
 
+        if (this.ctx.defaultSubscription) {
+            this.init(this.ctx.$container, this.ctx.defaultSubscription);
+        }
+    }
+
+    init($element, subscription) {
+        this.subscription = subscription;
+        this.$element = $element;
+        var colors = [];
+        for (var i = 0; i < this.subscription.data.length; i++) {
+            var series = this.subscription.data[i];
+            colors.push(series.dataKey.color);
+            var keySettings = series.dataKey.settings;
+
+            series.lines = {
+                fill: keySettings.fillLines === true,
+                show: this.chartType === 'line' ? keySettings.showLines !== false : keySettings.showLines === true
+            };
+
+            series.points = {
+                show: false,
+                radius: 8
+            };
+            if (keySettings.showPoints === true) {
+                series.points.show = true;
+                series.points.lineWidth = 5;
+                series.points.radius = 3;
+            }
+
+            if (this.chartType === 'line' && this.ctx.settings.smoothLines && !series.points.show) {
+                series.curvedLines = {
+                    apply: true
+                }
+            }
+
+            var lineColor = tinycolor(series.dataKey.color);
+            lineColor.setAlpha(.75);
+
+            series.highlightColor = lineColor.toRgbString();
+
+        }
+        this.options.colors = colors;
+        if (this.chartType === 'line' || this.chartType === 'bar') {
+            if (this.chartType === 'bar') {
+                this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6;
+            }
+            this.options.xaxis.min = this.subscription.timeWindow.minTime;
+            this.options.xaxis.max = this.subscription.timeWindow.maxTime;
+        }
+
         this.checkMouseEvents();
 
+        if (this.ctx.plot) {
+            this.ctx.plot.destroy();
+        }
         if (this.chartType === 'pie' && this.ctx.animatedPie) {
             this.ctx.pieDataAnimationDuration = 250;
-            this.ctx.pieData = angular.copy(this.ctx.data);
+            this.pieData = angular.copy(this.subscription.data);
             this.ctx.pieRenderedData = [];
             this.ctx.pieTargetData = [];
-            for (i = 0; i < this.ctx.data.length; i++) {
-                this.ctx.pieTargetData[i] = (this.ctx.data[i].data && this.ctx.data[i].data[0])
-                    ? this.ctx.data[i].data[0][1] : 0;
+            for (i = 0; i < this.subscription.data.length; i++) {
+                this.ctx.pieTargetData[i] = (this.subscription.data[i].data && this.subscription.data[i].data[0])
+                    ? this.subscription.data[i].data[0][1] : 0;
             }
             this.pieDataRendered();
-            this.ctx.plot = $.plot(this.ctx.$container, this.ctx.pieData, this.options);
+            this.ctx.plot = $.plot(this.$element, this.pieData, this.options);
         } else {
-            this.ctx.plot = $.plot(this.ctx.$container, this.ctx.data, this.options);
+            this.ctx.plot = $.plot(this.$element, this.subscription.data, this.options);
         }
     }
 
     update() {
-        if (!this.isMouseInteraction && this.ctx.plot) {
-            if (this.chartType === 'line' || this.chartType === 'bar') {
-                this.options.xaxis.min = this.ctx.timeWindow.minTime;
-                this.options.xaxis.max = this.ctx.timeWindow.maxTime;
-                this.ctx.plot.getOptions().xaxes[0].min = this.ctx.timeWindow.minTime;
-                this.ctx.plot.getOptions().xaxes[0].max = this.ctx.timeWindow.maxTime;
-                if (this.chartType === 'bar') {
-                    this.options.series.bars.barWidth = this.ctx.timeWindow.interval * 0.6;
-                    this.ctx.plot.getOptions().series.bars.barWidth = this.ctx.timeWindow.interval * 0.6;
-                }
-                this.ctx.plot.setData(this.ctx.data);
-                this.ctx.plot.setupGrid();
-                this.ctx.plot.draw();
-            } else if (this.chartType === 'pie') {
-                if (this.ctx.animatedPie) {
-                    this.nextPieDataAnimation(true);
-                } else {
-                    this.ctx.plot.setData(this.ctx.data);
+        if (this.updateTimeoutHandle) {
+            this.ctx.$scope.$timeout.cancel(this.updateTimeoutHandle);
+            this.updateTimeoutHandle = null;
+        }
+        if (this.subscription) {
+            if (!this.isMouseInteraction && this.ctx.plot) {
+                if (this.chartType === 'line' || this.chartType === 'bar') {
+                    this.options.xaxis.min = this.subscription.timeWindow.minTime;
+                    this.options.xaxis.max = this.subscription.timeWindow.maxTime;
+                    this.ctx.plot.getOptions().xaxes[0].min = this.subscription.timeWindow.minTime;
+                    this.ctx.plot.getOptions().xaxes[0].max = this.subscription.timeWindow.maxTime;
+                    if (this.chartType === 'bar') {
+                        this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6;
+                        this.ctx.plot.getOptions().series.bars.barWidth = this.subscription.timeWindow.interval * 0.6;
+                    }
+                    this.ctx.plot.setData(this.subscription.data);
+                    this.ctx.plot.setupGrid();
                     this.ctx.plot.draw();
+                } else if (this.chartType === 'pie') {
+                    if (this.ctx.animatedPie) {
+                        this.nextPieDataAnimation(true);
+                    } else {
+                        this.ctx.plot.setData(this.subscription.data);
+                        this.ctx.plot.draw();
+                    }
                 }
+            } else if (this.isMouseInteraction && this.ctx.plot){
+                var tbFlot = this;
+                this.updateTimeoutHandle = this.ctx.$scope.$timeout(function() {
+                    tbFlot.update();
+                }, 30, false);
             }
         }
     }
 
     resize() {
-        this.ctx.plot.resize();
-        if (this.chartType !== 'pie') {
-            this.ctx.plot.setupGrid();
+        if (this.ctx.plot) {
+            this.ctx.plot.resize();
+            if (this.chartType !== 'pie') {
+                this.ctx.plot.setupGrid();
+            }
+            this.ctx.plot.draw();
         }
-        this.ctx.plot.draw();
     }
 
     static get pieSettingsSchema() {
@@ -473,6 +515,11 @@ export default class TbFlot {
                         "type": "boolean",
                         "default": false
                     },
+                    "smoothLines": {
+                        "title": "Display smooth (curved) lines",
+                        "type": "boolean",
+                        "default": false
+                    },
                     "shadowSize": {
                         "title": "Shadow size",
                         "type": "number",
@@ -591,6 +638,7 @@ export default class TbFlot {
             },
             "form": [
                 "stack",
+                "smoothLines",
                 "shadowSize",
                 {
                     "key": "fontColor",
@@ -688,17 +736,19 @@ export default class TbFlot {
         var enabled = !this.ctx.isMobile &&  !this.ctx.isEdit;
         if (angular.isUndefined(this.mouseEventsEnabled) || this.mouseEventsEnabled != enabled) {
             this.mouseEventsEnabled = enabled;
-            if (enabled) {
-                this.enableMouseEvents();
-            } else {
-                this.disableMouseEvents();
-            }
-            if (this.ctx.plot) {
-                this.ctx.plot.destroy();
-                if (this.chartType === 'pie' && this.ctx.animatedPie) {
-                    this.ctx.plot = $.plot(this.ctx.$container, this.ctx.pieData, this.options);
+            if (this.$element) {
+                if (enabled) {
+                    this.enableMouseEvents();
                 } else {
-                    this.ctx.plot = $.plot(this.ctx.$container, this.ctx.data, this.options);
+                    this.disableMouseEvents();
+                }
+                if (this.ctx.plot) {
+                    this.ctx.plot.destroy();
+                    if (this.chartType === 'pie' && this.ctx.animatedPie) {
+                        this.ctx.plot = $.plot(this.$element, this.pieData, this.options);
+                    } else {
+                        this.ctx.plot = $.plot(this.$element, this.subscription.data, this.options);
+                    }
                 }
             }
         }
@@ -712,8 +762,8 @@ export default class TbFlot {
     }
 
     enableMouseEvents() {
-        this.ctx.$container.css('pointer-events','');
-        this.ctx.$container.addClass('mouse-events');
+        this.$element.css('pointer-events','');
+        this.$element.addClass('mouse-events');
         this.options.selection = { mode : 'x' };
 
         var tbFlot = this;
@@ -774,33 +824,33 @@ export default class TbFlot {
                     tbFlot.ctx.plot.unhighlight();
                 }
             };
-            this.ctx.$container.bind('plothover', this.flotHoverHandler);
+            this.$element.bind('plothover', this.flotHoverHandler);
         }
 
         if (!this.flotSelectHandler) {
             this.flotSelectHandler =  function (event, ranges) {
                 tbFlot.ctx.plot.clearSelection();
-                tbFlot.ctx.timewindowFunctions.onUpdateTimewindow(ranges.xaxis.from, ranges.xaxis.to);
+                tbFlot.subscription.onUpdateTimewindow(ranges.xaxis.from, ranges.xaxis.to);
             };
-            this.ctx.$container.bind('plotselected', this.flotSelectHandler);
+            this.$element.bind('plotselected', this.flotSelectHandler);
         }
         if (!this.dblclickHandler) {
             this.dblclickHandler =  function () {
-                tbFlot.ctx.timewindowFunctions.onResetTimewindow();
+                tbFlot.subscription.onResetTimewindow();
             };
-            this.ctx.$container.bind('dblclick', this.dblclickHandler);
+            this.$element.bind('dblclick', this.dblclickHandler);
         }
         if (!this.mousedownHandler) {
             this.mousedownHandler =  function () {
                 tbFlot.isMouseInteraction = true;
             };
-            this.ctx.$container.bind('mousedown', this.mousedownHandler);
+            this.$element.bind('mousedown', this.mousedownHandler);
         }
         if (!this.mouseupHandler) {
             this.mouseupHandler =  function () {
                 tbFlot.isMouseInteraction = false;
             };
-            this.ctx.$container.bind('mouseup', this.mouseupHandler);
+            this.$element.bind('mouseup', this.mouseupHandler);
         }
         if (!this.mouseleaveHandler) {
             this.mouseleaveHandler =  function () {
@@ -809,38 +859,38 @@ export default class TbFlot {
                 tbFlot.ctx.plot.unhighlight();
                 tbFlot.isMouseInteraction = false;
             };
-            this.ctx.$container.bind('mouseleave', this.mouseleaveHandler);
+            this.$element.bind('mouseleave', this.mouseleaveHandler);
         }
     }
 
     disableMouseEvents() {
-        this.ctx.$container.css('pointer-events','none');
-        this.ctx.$container.removeClass('mouse-events');
+        this.$element.css('pointer-events','none');
+        this.$element.removeClass('mouse-events');
         this.options.selection = { mode : null };
 
         if (this.flotHoverHandler) {
-            this.ctx.$container.unbind('plothover', this.flotHoverHandler);
+            this.$element.unbind('plothover', this.flotHoverHandler);
             this.flotHoverHandler = null;
         }
 
         if (this.flotSelectHandler) {
-            this.ctx.$container.unbind('plotselected', this.flotSelectHandler);
+            this.$element.unbind('plotselected', this.flotSelectHandler);
             this.flotSelectHandler = null;
         }
         if (this.dblclickHandler) {
-            this.ctx.$container.unbind('dblclick', this.dblclickHandler);
+            this.$element.unbind('dblclick', this.dblclickHandler);
             this.dblclickHandler = null;
         }
         if (this.mousedownHandler) {
-            this.ctx.$container.unbind('mousedown', this.mousedownHandler);
+            this.$element.unbind('mousedown', this.mousedownHandler);
             this.mousedownHandler = null;
         }
         if (this.mouseupHandler) {
-            this.ctx.$container.unbind('mouseup', this.mouseupHandler);
+            this.$element.unbind('mouseup', this.mouseupHandler);
             this.mouseupHandler = null;
         }
         if (this.mouseleaveHandler) {
-            this.ctx.$container.unbind('mouseleave', this.mouseleaveHandler);
+            this.$element.unbind('mouseleave', this.mouseleaveHandler);
             this.mouseleaveHandler = null;
         }
     }
@@ -910,7 +960,7 @@ export default class TbFlot {
                     value = series.data[hoverIndex][1];
                 }
 
-                if (series.stack) {
+                if (series.stack || (series.curvedLines && series.curvedLines.apply)) {
                     hoverIndex = this.findHoverIndexFromDataPoints(pos.x, series, hoverIndex);
                 }
                 results.seriesHover.push({
@@ -932,10 +982,10 @@ export default class TbFlot {
         for (var i = 0; i < this.ctx.pieTargetData.length; i++) {
             var value = this.ctx.pieTargetData[i] ? this.ctx.pieTargetData[i] : 0;
             this.ctx.pieRenderedData[i] = value;
-            if (!this.ctx.pieData[i].data[0]) {
-                this.ctx.pieData[i].data[0] = [0,0];
+            if (!this.pieData[i].data[0]) {
+                this.pieData[i].data[0] = [0,0];
             }
-            this.ctx.pieData[i].data[0][1] = value;
+            this.pieData[i].data[0][1] = value;
         }
     }
 
@@ -943,9 +993,9 @@ export default class TbFlot {
         if (start) {
             this.finishPieDataAnimation();
             this.ctx.pieAnimationStartTime = this.ctx.pieAnimationLastTime = Date.now();
-            for (var i = 0;  i < this.ctx.data.length; i++) {
-                this.ctx.pieTargetData[i] = (this.ctx.data[i].data && this.ctx.data[i].data[0])
-                    ? this.ctx.data[i].data[0][1] : 0;
+            for (var i = 0;  i < this.subscription.data.length; i++) {
+                this.ctx.pieTargetData[i] = (this.subscription.data[i].data && this.subscription.data[i].data[0])
+                    ? this.subscription.data[i].data[0][1] : 0;
             }
         }
         if (this.ctx.pieAnimationCaf) {
@@ -972,12 +1022,12 @@ export default class TbFlot {
                     var prevValue = this.ctx.pieRenderedData[i];
                     var targetValue = this.ctx.pieTargetData[i];
                     var value = prevValue + (targetValue - prevValue) * progress;
-                    if (!this.ctx.pieData[i].data[0]) {
-                        this.ctx.pieData[i].data[0] = [0,0];
+                    if (!this.pieData[i].data[0]) {
+                        this.pieData[i].data[0] = [0,0];
                     }
-                    this.ctx.pieData[i].data[0][1] = value;
+                    this.pieData[i].data[0][1] = value;
                 }
-                this.ctx.plot.setData(this.ctx.pieData);
+                this.ctx.plot.setData(this.pieData);
                 this.ctx.plot.draw();
                 this.ctx.pieAnimationLastTime = time;
             }
@@ -987,7 +1037,7 @@ export default class TbFlot {
 
     finishPieDataAnimation() {
         this.pieDataRendered();
-        this.ctx.plot.setData(this.ctx.pieData);
+        this.ctx.plot.setData(this.pieData);
         this.ctx.plot.draw();
     }
 }
diff --git a/ui/src/app/widget/lib/google-map.js b/ui/src/app/widget/lib/google-map.js
index 9722839..0e104eb 100644
--- a/ui/src/app/widget/lib/google-map.js
+++ b/ui/src/app/widget/lib/google-map.js
@@ -182,7 +182,7 @@ export default class TbGoogleMap {
     /* eslint-enable no-undef */
 
     /* eslint-disable no-undef */
-    createMarker(location, settings) {
+    createMarker(location, settings, onClickListener) {
         var height = 34;
         var pinColor = settings.color.substr(1);
         var pinImage = new google.maps.MarkerImage("http://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=%E2%80%A2|" + pinColor,
@@ -217,10 +217,21 @@ export default class TbGoogleMap {
             this.updateMarkerImage(marker, settings, settings.markerImage, settings.markerImageSize || 34);
         }
 
-        this.createTooltip(marker, settings.tooltipPattern, settings.tooltipReplaceInfo);
+        if (settings.displayTooltip) {
+            this.createTooltip(marker, settings.tooltipPattern, settings.tooltipReplaceInfo);
+        }
+
+        if (onClickListener) {
+            marker.addListener('click', onClickListener);
+        }
 
         return marker;
     }
+
+    removeMarker(marker) {
+        marker.setMap(null);
+    }
+
     /* eslint-enable no-undef */
 
     /* eslint-disable no-undef */
@@ -266,6 +277,10 @@ export default class TbGoogleMap {
     }
     /* eslint-enable no-undef */
 
+    removePolyline(polyline) {
+        polyline.setMap(null);
+    }
+
     /* eslint-disable no-undef */
     fitBounds(bounds) {
         if (this.dontFitMapBounds && this.defaultZoomLevel) {
diff --git a/ui/src/app/widget/lib/map-widget.js b/ui/src/app/widget/lib/map-widget.js
index be7b118..88ef1ca 100644
--- a/ui/src/app/widget/lib/map-widget.js
+++ b/ui/src/app/widget/lib/map-widget.js
@@ -19,11 +19,62 @@ import tinycolor from 'tinycolor2';
 import TbGoogleMap from './google-map';
 import TbOpenStreetMap from './openstreet-map';
 
+function procesTooltipPattern(tbMap, pattern, datasources, dsIndex) {
+    var match = tbMap.varsRegex.exec(pattern);
+    var replaceInfo = {};
+    replaceInfo.variables = [];
+    while (match !== null) {
+        var variableInfo = {};
+        variableInfo.dataKeyIndex = -1;
+        var variable = match[0];
+        var label = match[1];
+        var valDec = 2;
+        var splitVals = label.split(':');
+        if (splitVals.length > 1) {
+            label = splitVals[0];
+            valDec = parseFloat(splitVals[1]);
+        }
+        variableInfo.variable = variable;
+        variableInfo.valDec = valDec;
+
+        if (label.startsWith('#')) {
+            var keyIndexStr = label.substring(1);
+            var n = Math.floor(Number(keyIndexStr));
+            if (String(n) === keyIndexStr && n >= 0) {
+                variableInfo.dataKeyIndex = n;
+            }
+        }
+        if (variableInfo.dataKeyIndex === -1) {
+            var offset = 0;
+            for (var i=0;i<datasources.length;i++) {
+                var datasource = datasources[i];
+                if (angular.isUndefined(dsIndex) || dsIndex == i) {
+                    for (var k = 0; k < datasource.dataKeys.length; k++) {
+                        var dataKey = datasource.dataKeys[k];
+                        if (dataKey.label === label) {
+                            variableInfo.dataKeyIndex = offset + k;
+                            break;
+                        }
+                    }
+                }
+                offset += datasource.dataKeys.length;
+            }
+        }
+        replaceInfo.variables.push(variableInfo);
+        match = tbMap.varsRegex.exec(pattern);
+    }
+    return replaceInfo;
+}
+
+
 export default class TbMapWidget {
-    constructor(mapProvider, drawRoutes, ctx) {
+    constructor(mapProvider, drawRoutes, ctx, useDynamicLocations, $element) {
 
         var tbMap = this;
         this.ctx = ctx;
+        if (!$element) {
+            $element = ctx.$container;
+        }
 
         this.drawRoutes = drawRoutes;
         this.markers = [];
@@ -35,6 +86,9 @@ export default class TbMapWidget {
 
         var settings = ctx.settings;
 
+        this.callbacks = {};
+        this.callbacks.onLocationClick = function(){};
+
         if (settings.defaultZoomLevel) {
             if (settings.defaultZoomLevel > 0 && settings.defaultZoomLevel < 21) {
                 this.defaultZoomLevel = Math.floor(settings.defaultZoomLevel);
@@ -43,52 +97,116 @@ export default class TbMapWidget {
 
         this.dontFitMapBounds = settings.fitMapBounds === false;
 
-        function procesTooltipPattern(pattern) {
-            var match = tbMap.varsRegex.exec(pattern);
-            var replaceInfo = {};
-            replaceInfo.variables = [];
-            while (match !== null) {
-                var variableInfo = {};
-                variableInfo.dataKeyIndex = -1;
-                var variable = match[0];
-                var label = match[1];
-                var valDec = 2;
-                var splitVals = label.split(':');
-                if (splitVals.length > 1) {
-                    label = splitVals[0];
-                    valDec = parseFloat(splitVals[1]);
+        if (!useDynamicLocations) {
+            this.subscription = this.ctx.defaultSubscription;
+            this.configureLocationsFromSettings();
+        }
+
+        var minZoomLevel = this.drawRoutes ? 18 : 15;
+
+        var initCallback = function() {
+              tbMap.update();
+              tbMap.resize();
+        };
+
+        if (mapProvider === 'google-map') {
+            this.map = new TbGoogleMap($element, initCallback, this.defaultZoomLevel, this.dontFitMapBounds, minZoomLevel, settings.gmApiKey, settings.gmDefaultMapType);
+        } else if (mapProvider === 'openstreet-map') {
+            this.map = new TbOpenStreetMap($element, initCallback, this.defaultZoomLevel, this.dontFitMapBounds, minZoomLevel);
+        }
+
+    }
+
+    setCallbacks(callbacks) {
+        Object.assign(this.callbacks, callbacks);
+    }
+
+    clearLocations() {
+        if (this.locations) {
+            var tbMap = this;
+            this.locations.forEach(function(location) {
+                if (location.marker) {
+                    tbMap.map.removeMarker(location.marker);
                 }
-                variableInfo.variable = variable;
-                variableInfo.valDec = valDec;
-
-                if (label.startsWith('#')) {
-                    var keyIndexStr = label.substring(1);
-                    var n = Math.floor(Number(keyIndexStr));
-                    if (String(n) === keyIndexStr && n >= 0) {
-                        variableInfo.dataKeyIndex = n;
-                    }
+                if (location.polyline) {
+                    tbMap.map.removePolyline(location.polyline);
                 }
-                if (variableInfo.dataKeyIndex === -1) {
-                    var offset = 0;
-                    for (var i=0;i<ctx.datasources.length;i++) {
-                        var datasource = ctx.datasources[i];
-                        for (var k = 0; k < datasource.dataKeys.length; k++) {
-                            var dataKey = datasource.dataKeys[k];
-                            if (dataKey.label === label) {
-                                variableInfo.dataKeyIndex = offset + k;
-                                break;
-                            }
+            });
+            this.locations = null;
+            this.markers = [];
+            if (this.drawRoutes) {
+                this.polylines = [];
+            }
+        }
+    }
+
+    configureLocationsFromSubscription(subscription, subscriptionLocationSettings) {
+        this.subscription = subscription;
+        this.clearLocations();
+        this.locationsSettings = [];
+        var latKeyName = subscriptionLocationSettings.latKeyName;
+        var lngKeyName = subscriptionLocationSettings.lngKeyName;
+        var index = 0;
+        for (var i=0;i<subscription.datasources.length;i++) {
+            var datasource = subscription.datasources[i];
+            var dataKeys = datasource.dataKeys;
+            var latKeyIndex = -1;
+            var lngKeyIndex = -1;
+            var localLatKeyName = latKeyName;
+            var localLngKeyName = lngKeyName;
+            for (var k=0;k<dataKeys.length;k++) {
+                var dataKey = dataKeys[k];
+                if (dataKey.name === latKeyName) {
+                    latKeyIndex = index;
+                    localLatKeyName = localLatKeyName + index;
+                    dataKey.locationAttrName = localLatKeyName;
+                } else if (dataKey.name === lngKeyName) {
+                    lngKeyIndex = index;
+                    localLngKeyName = localLngKeyName + index;
+                    dataKey.locationAttrName = localLngKeyName;
+                }
+                if (latKeyIndex > -1 && lngKeyIndex > -1) {
+                    var locationsSettings = {
+                        latKeyName: localLatKeyName,
+                        lngKeyName: localLngKeyName,
+                        showLabel: subscriptionLocationSettings.showLabel !== false,
+                        displayTooltip: subscriptionLocationSettings.displayTooltip !== false,
+                        label: datasource.name,
+                        labelColor: subscriptionLocationSettings.labelColor || this.ctx.widgetConfig.color || '#000000',
+                        color: "#FE7569",
+                        useColorFunction: false,
+                        colorFunction: null,
+                        markerImage: null,
+                        markerImageSize: 34,
+                        useMarkerImage: false,
+                        useMarkerImageFunction: false,
+                        markerImageFunction: null,
+                        markerImages: [],
+                        tooltipPattern: subscriptionLocationSettings.tooltipPattern || "<b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}"
+                    };
+
+                    locationsSettings.tooltipReplaceInfo = procesTooltipPattern(this, locationsSettings.tooltipPattern, this.subscription.datasources, i);
+
+                    locationsSettings.useColorFunction = subscriptionLocationSettings.useColorFunction === true;
+                    if (angular.isDefined(subscriptionLocationSettings.colorFunction) && subscriptionLocationSettings.colorFunction.length > 0) {
+                        try {
+                            locationsSettings.colorFunction = new Function('data, dsData, dsIndex', subscriptionLocationSettings.colorFunction);
+                        } catch (e) {
+                            locationsSettings.colorFunction = null;
                         }
-                        offset += datasource.dataKeys.length;
                     }
+
+                    this.locationsSettings.push(locationsSettings);
+                    latKeyIndex = -1;
+                    lngKeyIndex = -1;
                 }
-                replaceInfo.variables.push(variableInfo);
-                match = tbMap.varsRegex.exec(pattern);
+                index++;
             }
-            return replaceInfo;
         }
+    }
 
-        var configuredLocationsSettings = drawRoutes ? settings.routesSettings : settings.markersSettings;
+    configureLocationsFromSettings() {
+        var configuredLocationsSettings = this.drawRoutes ? this.ctx.settings.routesSettings : this.ctx.settings.markersSettings;
         if (!configuredLocationsSettings) {
             configuredLocationsSettings = [];
         }
@@ -98,8 +216,9 @@ export default class TbMapWidget {
                 latKeyName: "lat",
                 lngKeyName: "lng",
                 showLabel: true,
+                displayTooltip: true,
                 label: "",
-                labelColor: ctx.widgetConfig.color || '#000000',
+                labelColor: this.ctx.widgetConfig.color || '#000000',
                 color: "#FE7569",
                 useColorFunction: false,
                 colorFunction: null,
@@ -112,7 +231,7 @@ export default class TbMapWidget {
                 tooltipPattern: "<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${lng:7}"
             };
 
-            if (drawRoutes) {
+            if (this.drawRoutes) {
                 this.locationsSettings[i].strokeWeight = 2;
                 this.locationsSettings[i].strokeOpacity = 1.0;
             }
@@ -123,7 +242,7 @@ export default class TbMapWidget {
 
                 this.locationsSettings[i].tooltipPattern = configuredLocationsSettings[i].tooltipPattern || "<b>Latitude:</b> ${"+this.locationsSettings[i].latKeyName+":7}<br/><b>Longitude:</b> ${"+this.locationsSettings[i].lngKeyName+":7}";
 
-                this.locationsSettings[i].tooltipReplaceInfo = procesTooltipPattern(this.locationsSettings[i].tooltipPattern);
+                this.locationsSettings[i].tooltipReplaceInfo = procesTooltipPattern(this, this.locationsSettings[i].tooltipPattern, this.subscription.datasources);
 
                 this.locationsSettings[i].showLabel = configuredLocationsSettings[i].showLabel !== false;
                 this.locationsSettings[i].label = configuredLocationsSettings[i].label || this.locationsSettings[i].label;
@@ -132,7 +251,7 @@ export default class TbMapWidget {
                 this.locationsSettings[i].useColorFunction = configuredLocationsSettings[i].useColorFunction === true;
                 if (angular.isDefined(configuredLocationsSettings[i].colorFunction) && configuredLocationsSettings[i].colorFunction.length > 0) {
                     try {
-                        this.locationsSettings[i].colorFunction = new Function('data', configuredLocationsSettings[i].colorFunction);
+                        this.locationsSettings[i].colorFunction = new Function('data, dsData, dsIndex', configuredLocationsSettings[i].colorFunction);
                     } catch (e) {
                         this.locationsSettings[i].colorFunction = null;
                     }
@@ -141,7 +260,7 @@ export default class TbMapWidget {
                 this.locationsSettings[i].useMarkerImageFunction = configuredLocationsSettings[i].useMarkerImageFunction === true;
                 if (angular.isDefined(configuredLocationsSettings[i].markerImageFunction) && configuredLocationsSettings[i].markerImageFunction.length > 0) {
                     try {
-                        this.locationsSettings[i].markerImageFunction = new Function('data, images', configuredLocationsSettings[i].markerImageFunction);
+                        this.locationsSettings[i].markerImageFunction = new Function('data, images, dsData, dsIndex', configuredLocationsSettings[i].markerImageFunction);
                     } catch (e) {
                         this.locationsSettings[i].markerImageFunction = null;
                     }
@@ -157,26 +276,12 @@ export default class TbMapWidget {
                     this.locationsSettings[i].markerImageSize = configuredLocationsSettings[i].markerImageSize || 34;
                 }
 
-                if (drawRoutes) {
+                if (this.drawRoutes) {
                     this.locationsSettings[i].strokeWeight = configuredLocationsSettings[i].strokeWeight || this.locationsSettings[i].strokeWeight;
                     this.locationsSettings[i].strokeOpacity = angular.isDefined(configuredLocationsSettings[i].strokeOpacity) ? configuredLocationsSettings[i].strokeOpacity : this.locationsSettings[i].strokeOpacity;
                 }
             }
         }
-
-        var minZoomLevel = this.drawRoutes ? 18 : 15;
-
-        var initCallback = function() {
-              tbMap.update();
-              tbMap.resize();
-        };
-
-        if (mapProvider === 'google-map') {
-            this.map = new TbGoogleMap(ctx.$container, initCallback, this.defaultZoomLevel, this.dontFitMapBounds, minZoomLevel, settings.gmApiKey, settings.gmDefaultMapType);
-        } else if (mapProvider === 'openstreet-map') {
-            this.map = new TbOpenStreetMap(ctx.$container, initCallback, this.defaultZoomLevel, this.dontFitMapBounds, minZoomLevel);
-        }
-
     }
 
     update() {
@@ -231,22 +336,22 @@ export default class TbMapWidget {
             return true;
         }
 
-        function calculateLocationColor(settings, dataMap) {
-            if (settings.useColorFunction && settings.colorFunction) {
+        function calculateLocationColor(location, dataMap) {
+            if (location.settings.useColorFunction && location.settings.colorFunction) {
                 var color = '#FE7569';
                 try {
-                    color = settings.colorFunction(dataMap);
+                    color = location.settings.colorFunction(dataMap.dataMap, dataMap.dsDataMap, location.dsIndex);
                 } catch (e) {
                     color = '#FE7569';
                 }
                 return tinycolor(color).toHexString();
             } else {
-                return settings.color;
+                return location.settings.color;
             }
         }
 
         function updateLocationColor(location, dataMap) {
-            var color = calculateLocationColor(location.settings, dataMap);
+            var color = calculateLocationColor(location, dataMap);
             if (!location.settings.calculatedColor || location.settings.calculatedColor !== color) {
                 if (!location.settings.useMarkerImage && !location.settings.useMarkerImageFunction) {
                     tbMap.map.updateMarkerColor(location.marker, color);
@@ -258,11 +363,11 @@ export default class TbMapWidget {
             }
         }
 
-        function calculateLocationMarkerImage(settings, dataMap) {
-            if (settings.useMarkerImageFunction && settings.markerImageFunction) {
+        function calculateLocationMarkerImage(location, dataMap) {
+            if (location.settings.useMarkerImageFunction && location.settings.markerImageFunction) {
                 var image = null;
                 try {
-                    image = settings.markerImageFunction(dataMap, settings.markerImages);
+                    image = location.settings.markerImageFunction(dataMap.dataMap, location.settings.markerImages, dataMap.dsDataMap, location.dsIndex);
                 } catch (e) {
                     image = null;
                 }
@@ -273,7 +378,7 @@ export default class TbMapWidget {
         }
 
         function updateLocationMarkerImage(location, dataMap) {
-            var image = calculateLocationMarkerImage(location.settings, dataMap);
+            var image = calculateLocationMarkerImage(location, dataMap);
             if (image != null && (!location.settings.calculatedImage || !angular.equals(location.settings.calculatedImage, image))) {
                 tbMap.map.updateMarkerImage(location.marker, location.settings, image.url, image.size);
                 location.settings.calculatedImage = image;
@@ -306,7 +411,11 @@ export default class TbMapWidget {
                         if (latLngs.length > 0) {
                             var markerLocation = latLngs[latLngs.length-1];
                             if (!location.marker) {
-                                location.marker = tbMap.map.createMarker(markerLocation, location.settings);
+                                location.marker = tbMap.map.createMarker(markerLocation, location.settings,
+                                    function() {
+                                        tbMap.callbacks.onLocationClick(location);
+                                    }
+                                );
                             } else {
                                 tbMap.map.setMarkerPosition(location.marker, markerLocation);
                             }
@@ -328,7 +437,9 @@ export default class TbMapWidget {
                         lng = lngData[lngData.length-1][1];
                         latLng = tbMap.map.createLatLng(lat, lng);
                         if (!location.marker) {
-                            location.marker = tbMap.map.createMarker(latLng, location.settings);
+                            location.marker = tbMap.map.createMarker(latLng, location.settings, function() {
+                                tbMap.callbacks.onLocationClick(location);
+                            });
                             tbMap.markers.push(location.marker);
                             locationChanged = true;
                         } else {
@@ -345,8 +456,12 @@ export default class TbMapWidget {
             return locationChanged;
         }
 
-        function toLabelValueMap(data) {
+        function toLabelValueMap(data, datasources) {
             var dataMap = {};
+            var dsDataMap = [];
+            for (var d=0;d<datasources.length;d++) {
+                dsDataMap[d] = {};
+            }
             for (var i = 0; i < data.length; i++) {
                 var dataKey = data[i].dataKey;
                 var label = dataKey.label;
@@ -356,30 +471,44 @@ export default class TbMapWidget {
                     val = keyData[keyData.length-1][1];
                 }
                 dataMap[label] = val;
+                var dsIndex = datasources.indexOf(data[i].datasource);
+                dsDataMap[dsIndex][label] = val;
             }
-            return dataMap;
+            return {
+                dataMap: dataMap,
+                dsDataMap: dsDataMap
+            };
         }
 
-        function loadLocations(data) {
+        function loadLocations(data, datasources) {
             var bounds = tbMap.map.createBounds();
             tbMap.locations = [];
-            var dataMap = toLabelValueMap(data);
+            var dataMap = toLabelValueMap(data, datasources);
             for (var l=0; l < tbMap.locationsSettings.length; l++) {
                 var locationSettings = tbMap.locationsSettings[l];
                 var latIndex = -1;
                 var lngIndex = -1;
                 for (var i = 0; i < data.length; i++) {
                     var dataKey = data[i].dataKey;
-                    if (dataKey.label === locationSettings.latKeyName) {
+                    var nameToCheck;
+                    if (dataKey.locationAttrName) {
+                        nameToCheck = dataKey.locationAttrName;
+                    } else {
+                        nameToCheck = dataKey.label;
+                    }
+                    if (nameToCheck === locationSettings.latKeyName) {
                         latIndex = i;
-                    } else if (dataKey.label === locationSettings.lngKeyName) {
+                    } else if (nameToCheck === locationSettings.lngKeyName) {
                         lngIndex = i;
                     }
                 }
                 if (latIndex > -1 && lngIndex > -1) {
+                    var ds = data[latIndex].datasource;
+                    var dsIndex = datasources.indexOf(ds);
                     var location = {
                         latIndex: latIndex,
                         lngIndex: lngIndex,
+                        dsIndex: dsIndex,
                         settings: locationSettings
                     };
                     tbMap.locations.push(location);
@@ -394,10 +523,10 @@ export default class TbMapWidget {
             tbMap.map.fitBounds(bounds);
         }
 
-        function updateLocations(data) {
+        function updateLocations(data, datasources) {
             var locationsChanged = false;
             var bounds = tbMap.map.createBounds();
-            var dataMap = toLabelValueMap(data);
+            var dataMap = toLabelValueMap(data, datasources);
             for (var p = 0; p < tbMap.locations.length; p++) {
                 var location = tbMap.locations[p];
                 locationsChanged |= updateLocation(location, data, dataMap);
@@ -412,36 +541,36 @@ export default class TbMapWidget {
             }
         }
 
-        if (this.map && this.map.inited()) {
-            if (this.ctx.data) {
+        if (this.map && this.map.inited() && this.subscription) {
+            if (this.subscription.data) {
                 if (!this.locations) {
-                    loadLocations(this.ctx.data);
+                    loadLocations(this.subscription.data, this.subscription.datasources);
                 } else {
-                    updateLocations(this.ctx.data);
+                    updateLocations(this.subscription.data, this.subscription.datasources);
                 }
-            }
-            var tooltips = this.map.getTooltips();
-            for (var t=0; t < tooltips.length; t++) {
-                var tooltip = tooltips[t];
-                var text = tooltip.pattern;
-                var replaceInfo = tooltip.replaceInfo;
-                for (var v = 0; v < replaceInfo.variables.length; v++) {
-                    var variableInfo = replaceInfo.variables[v];
-                    var txtVal = '';
-                    if (variableInfo.dataKeyIndex > -1) {
-                        var varData = this.ctx.data[variableInfo.dataKeyIndex].data;
-                        if (varData.length > 0) {
-                            var val = varData[varData.length - 1][1];
-                            if (isNumber(val)) {
-                                txtVal = padValue(val, variableInfo.valDec, 0);
-                            } else {
-                                txtVal = val;
+                var tooltips = this.map.getTooltips();
+                for (var t=0; t < tooltips.length; t++) {
+                    var tooltip = tooltips[t];
+                    var text = tooltip.pattern;
+                    var replaceInfo = tooltip.replaceInfo;
+                    for (var v = 0; v < replaceInfo.variables.length; v++) {
+                        var variableInfo = replaceInfo.variables[v];
+                        var txtVal = '';
+                        if (variableInfo.dataKeyIndex > -1 && this.subscription.data[variableInfo.dataKeyIndex]) {
+                            var varData = this.subscription.data[variableInfo.dataKeyIndex].data;
+                            if (varData.length > 0) {
+                                var val = varData[varData.length - 1][1];
+                                if (isNumber(val)) {
+                                    txtVal = padValue(val, variableInfo.valDec, 0);
+                                } else {
+                                    txtVal = val;
+                                }
                             }
                         }
+                        text = text.split(variableInfo.variable).join(txtVal);
                     }
-                    text = text.split(variableInfo.variable).join(txtVal);
+                    tooltip.popup.setContent(text);
                 }
-                tooltip.popup.setContent(text);
             }
         }
     }
@@ -449,7 +578,7 @@ export default class TbMapWidget {
     resize() {
         if (this.map && this.map.inited()) {
             this.map.invalidateSize();
-            if (this.locations && this.locations.size > 0) {
+            if (this.locations && this.locations.length > 0) {
                 var bounds = this.map.createBounds();
                 for (var m = 0; m < this.markers.length; m++) {
                     this.map.extendBoundsWithMarker(bounds, this.markers[m]);
diff --git a/ui/src/app/widget/lib/openstreet-map.js b/ui/src/app/widget/lib/openstreet-map.js
index aacb505..65f7c7f 100644
--- a/ui/src/app/widget/lib/openstreet-map.js
+++ b/ui/src/app/widget/lib/openstreet-map.js
@@ -85,7 +85,7 @@ export default class TbOpenStreetMap {
         testImage.src = image;
     }
 
-    createMarker(location, settings) {
+    createMarker(location, settings, onClickListener) {
         var height = 34;
         var pinColor = settings.color.substr(1);
         var icon = L.icon({
@@ -109,11 +109,21 @@ export default class TbOpenStreetMap {
             this.updateMarkerImage(marker, settings, settings.markerImage, settings.markerImageSize || 34);
         }
 
-        this.createTooltip(marker, settings.tooltipPattern, settings.tooltipReplaceInfo);
+        if (settings.displayTooltip) {
+            this.createTooltip(marker, settings.tooltipPattern, settings.tooltipReplaceInfo);
+        }
+
+        if (onClickListener) {
+            marker.on('click', onClickListener);
+        }
 
         return marker;
     }
 
+    removeMarker(marker) {
+        this.map.removeLayer(marker);
+    }
+
     createTooltip(marker, pattern, replaceInfo) {
         var popup = L.popup();
         popup.setContent('');
@@ -145,6 +155,10 @@ export default class TbOpenStreetMap {
         return polyline;
     }
 
+    removePolyline(polyline) {
+        this.map.removeLayer(polyline);
+    }
+
     fitBounds(bounds) {
         if (bounds.isValid()) {
             if (this.dontFitMapBounds && this.defaultZoomLevel) {
diff --git a/ui/src/app/widget/lib/timeseries-table-widget.js b/ui/src/app/widget/lib/timeseries-table-widget.js
new file mode 100644
index 0000000..c55c519
--- /dev/null
+++ b/ui/src/app/widget/lib/timeseries-table-widget.js
@@ -0,0 +1,325 @@
+/*
+ * Copyright © 2016-2017 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import './timeseries-table-widget.scss';
+
+/* eslint-disable import/no-unresolved, import/default */
+
+import timeseriesTableWidgetTemplate from './timeseries-table-widget.tpl.html';
+
+/* eslint-enable import/no-unresolved, import/default */
+
+import tinycolor from 'tinycolor2';
+import cssjs from '../../../vendor/css.js/css';
+
+export default angular.module('thingsboard.widgets.timeseriesTableWidget', [])
+    .directive('tbTimeseriesTableWidget', TimeseriesTableWidget)
+    .name;
+
+/*@ngInject*/
+function TimeseriesTableWidget() {
+    return {
+        restrict: "E",
+        scope: true,
+        bindToController: {
+            tableId: '=',
+            config: '=',
+            datasources: '=',
+            data: '='
+        },
+        controller: TimeseriesTableWidgetController,
+        controllerAs: 'vm',
+        templateUrl: timeseriesTableWidgetTemplate
+    };
+}
+
+/*@ngInject*/
+function TimeseriesTableWidgetController($element, $scope, $filter) {
+    var vm = this;
+
+    vm.sources = [];
+    vm.sourceIndex = 0;
+
+    $scope.$watch('vm.config', function() {
+       if (vm.config) {
+           vm.settings = vm.config.settings;
+           vm.widgetConfig = vm.config.widgetConfig;
+           initialize();
+       }
+    });
+
+    function initialize() {
+        vm.showTimestamp = vm.settings.showTimestamp !== false;
+        var origColor = vm.widgetConfig.color || 'rgba(0, 0, 0, 0.87)';
+        var defaultColor = tinycolor(origColor);
+        var mdDark = defaultColor.setAlpha(0.87).toRgbString();
+        var mdDarkSecondary = defaultColor.setAlpha(0.54).toRgbString();
+        var mdDarkDisabled = defaultColor.setAlpha(0.26).toRgbString();
+        //var mdDarkIcon = mdDarkSecondary;
+        var mdDarkDivider = defaultColor.setAlpha(0.12).toRgbString();
+
+        var cssString = 'table.md-table th.md-column {\n'+
+            'color: ' + mdDarkSecondary + ';\n'+
+            '}\n'+
+            'table.md-table th.md-column md-icon.md-sort-icon {\n'+
+            'color: ' + mdDarkDisabled + ';\n'+
+            '}\n'+
+            'table.md-table th.md-column.md-active, table.md-table th.md-column.md-active md-icon {\n'+
+            'color: ' + mdDark + ';\n'+
+            '}\n'+
+            'table.md-table td.md-cell {\n'+
+            'color: ' + mdDark + ';\n'+
+            'border-top: 1px '+mdDarkDivider+' solid;\n'+
+            '}\n'+
+            'table.md-table td.md-cell.md-placeholder {\n'+
+            'color: ' + mdDarkDisabled + ';\n'+
+            '}\n'+
+            'table.md-table td.md-cell md-select > .md-select-value > span.md-select-icon {\n'+
+            'color: ' + mdDarkSecondary + ';\n'+
+            '}\n'+
+            '.md-table-pagination {\n'+
+            'color: ' + mdDarkSecondary + ';\n'+
+            'border-top: 1px '+mdDarkDivider+' solid;\n'+
+            '}\n'+
+            '.md-table-pagination .buttons md-icon {\n'+
+            'color: ' + mdDarkSecondary + ';\n'+
+            '}\n'+
+            '.md-table-pagination md-select:not([disabled]):focus .md-select-value {\n'+
+            'color: ' + mdDarkSecondary + ';\n'+
+            '}';
+
+        var cssParser = new cssjs();
+        cssParser.testMode = false;
+        var namespace = 'ts-table-' + hashCode(cssString);
+        cssParser.cssPreviewNamespace = namespace;
+        cssParser.createStyleElement(namespace, cssString);
+        $element.addClass(namespace);
+
+        function hashCode(str) {
+            var hash = 0;
+            var i, char;
+            if (str.length === 0) return hash;
+            for (i = 0; i < str.length; i++) {
+                char = str.charCodeAt(i);
+                hash = ((hash << 5) - hash) + char;
+                hash = hash & hash;
+            }
+            return hash;
+        }
+    }
+
+    $scope.$watch('vm.datasources', function() {
+        updateDatasources();
+    });
+
+    $scope.$on('timeseries-table-data-updated', function(event, tableId) {
+        if (vm.tableId == tableId) {
+            dataUpdated();
+        }
+    });
+
+    function dataUpdated() {
+        for (var s=0; s < vm.sources.length; s++) {
+            var source = vm.sources[s];
+            source.rawData = vm.data.slice(source.keyStartIndex, source.keyEndIndex);
+        }
+        updateSourceData(vm.sources[vm.sourceIndex]);
+        $scope.$digest();
+    }
+
+    vm.onPaginate = function(source) {
+        updatePage(source);
+    }
+
+    vm.onReorder = function(source) {
+        reorder(source);
+        updatePage(source);
+    }
+
+    vm.cellStyle = function(source, index, value) {
+        var style = {};
+        if (index > 0) {
+            var styleInfo = source.ts.stylesInfo[index-1];
+            if (styleInfo.useCellStyleFunction && styleInfo.cellStyleFunction) {
+                try {
+                    style = styleInfo.cellStyleFunction(value);
+                } catch (e) {
+                    style = {};
+                }
+            }
+        }
+        return style;
+    }
+
+    vm.cellContent = function(source, index, row, value) {
+        if (index === 0) {
+            return $filter('date')(value, 'yyyy-MM-dd HH:mm:ss');
+        } else {
+            var strContent = '';
+            if (angular.isDefined(value)) {
+                strContent = ''+value;
+            }
+            var content = strContent;
+            var contentInfo = source.ts.contentsInfo[index-1];
+            if (contentInfo.useCellContentFunction && contentInfo.cellContentFunction) {
+                try {
+                    var rowData = source.ts.rowDataTemplate;
+                    rowData['Timestamp'] = row[0];
+                    for (var h=0; h < source.ts.header.length; h++) {
+                        var headerInfo = source.ts.header[h];
+                        rowData[headerInfo.dataKey.name] = row[headerInfo.index];
+                    }
+                    content = contentInfo.cellContentFunction(value, rowData, $filter);
+                } catch (e) {
+                    content = strContent;
+                }
+            }
+            return content;
+        }
+    }
+
+    $scope.$watch('vm.sourceIndex', function(newIndex, oldIndex) {
+        if (newIndex != oldIndex) {
+            updateSourceData(vm.sources[vm.sourceIndex]);
+        }
+    });
+
+    function updateDatasources() {
+        vm.sources = [];
+        vm.sourceIndex = 0;
+        var keyOffset = 0;
+        if (vm.datasources) {
+            for (var ds = 0; ds < vm.datasources.length; ds++) {
+                var source = {};
+                var datasource = vm.datasources[ds];
+                source.keyStartIndex = keyOffset;
+                keyOffset += datasource.dataKeys.length;
+                source.keyEndIndex = keyOffset;
+                source.datasource = datasource;
+                source.data = [];
+                source.rawData = [];
+                source.query = {
+                    limit: 5,
+                    page: 1,
+                    order: '-0'
+                }
+                source.ts = {
+                    header: [],
+                    count: 0,
+                    data: [],
+                    stylesInfo: [],
+                    contentsInfo: [],
+                    rowDataTemplate: {}
+                }
+                source.ts.rowDataTemplate['Timestamp'] = null;
+                for (var a = 0; a < datasource.dataKeys.length; a++ ) {
+                    var dataKey = datasource.dataKeys[a];
+                    var keySettings = dataKey.settings;
+                    source.ts.header.push({
+                        index: a+1,
+                        dataKey: dataKey
+                    });
+                    source.ts.rowDataTemplate[dataKey.label] = null;
+
+                    var cellStyleFunction = null;
+                    var useCellStyleFunction = false;
+
+                    if (keySettings.useCellStyleFunction === true) {
+                        if (angular.isDefined(keySettings.cellStyleFunction) && keySettings.cellStyleFunction.length > 0) {
+                            try {
+                                cellStyleFunction = new Function('value', keySettings.cellStyleFunction);
+                                useCellStyleFunction = true;
+                            } catch (e) {
+                                cellStyleFunction = null;
+                                useCellStyleFunction = false;
+                            }
+                        }
+                    }
+
+                    source.ts.stylesInfo.push({
+                        useCellStyleFunction: useCellStyleFunction,
+                        cellStyleFunction: cellStyleFunction
+                    });
+
+                    var cellContentFunction = null;
+                    var useCellContentFunction = false;
+
+                    if (keySettings.useCellContentFunction === true) {
+                        if (angular.isDefined(keySettings.cellContentFunction) && keySettings.cellContentFunction.length > 0) {
+                            try {
+                                cellContentFunction = new Function('value, rowData, filter', keySettings.cellContentFunction);
+                                useCellContentFunction = true;
+                            } catch (e) {
+                                cellContentFunction = null;
+                                useCellContentFunction = false;
+                            }
+                        }
+                    }
+
+                    source.ts.contentsInfo.push({
+                        useCellContentFunction: useCellContentFunction,
+                        cellContentFunction: cellContentFunction
+                    });
+
+                }
+                vm.sources.push(source);
+            }
+        }
+    }
+
+    function updatePage(source) {
+        var startIndex = source.query.limit * (source.query.page - 1);
+        source.ts.data = source.data.slice(startIndex, startIndex + source.query.limit);
+    }
+
+    function reorder(source) {
+        source.data = $filter('orderBy')(source.data, source.query.order);
+    }
+
+    function convertData(data) {
+        var rowsMap = {};
+        for (var d = 0; d < data.length; d++) {
+            var columnData = data[d].data;
+            for (var i = 0; i < columnData.length; i++) {
+                var cellData = columnData[i];
+                var timestamp = cellData[0];
+                var row = rowsMap[timestamp];
+                if (!row) {
+                    row = [];
+                    row[0] = timestamp;
+                    for (var c = 0; c < data.length; c++) {
+                        row[c+1] = undefined;
+                    }
+                    rowsMap[timestamp] = row;
+                }
+                row[d+1] = cellData[1];
+            }
+        }
+        var rows = [];
+        for (var t in rowsMap) {
+            rows.push(rowsMap[t]);
+        }
+        return rows;
+    }
+
+    function updateSourceData(source) {
+        source.data = convertData(source.rawData);
+        source.ts.count = source.data.length;
+        reorder(source);
+        updatePage(source);
+    }
+
+}
\ No newline at end of file
diff --git a/ui/src/app/widget/lib/timeseries-table-widget.scss b/ui/src/app/widget/lib/timeseries-table-widget.scss
new file mode 100644
index 0000000..99a0653
--- /dev/null
+++ b/ui/src/app/widget/lib/timeseries-table-widget.scss
@@ -0,0 +1,29 @@
+/**
+ * Copyright © 2016-2017 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+tb-timeseries-table-widget {
+    table.md-table thead.md-head>tr.md-row {
+        height: 40px;
+    }
+
+    table.md-table tbody.md-body>tr.md-row, table.md-table tfoot.md-foot>tr.md-row {
+        height: 38px;
+    }
+
+    .md-table-pagination>* {
+        height: 46px;
+    }
+}
diff --git a/ui/src/app/widget/lib/timeseries-table-widget.tpl.html b/ui/src/app/widget/lib/timeseries-table-widget.tpl.html
new file mode 100644
index 0000000..2b6d72c
--- /dev/null
+++ b/ui/src/app/widget/lib/timeseries-table-widget.tpl.html
@@ -0,0 +1,43 @@
+<!--
+
+    Copyright © 2016-2017 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.
+
+-->
+
+<md-tabs md-selected="vm.sourceIndex" ng-class="{'tb-headless': vm.sources.length === 1}"
+         id="tabs" md-border-bottom flex class="tb-absolute-fill">
+    <md-tab ng-repeat="source in vm.sources" label="{{ source.datasource.name }}">
+        <md-table-container>
+            <table md-table>
+                <thead md-head md-order="source.query.order" md-on-reorder="vm.onReorder(source)">
+                <tr md-row>
+                    <th ng-show="vm.showTimestamp" md-column md-order-by="0"><span>Timestamp</span></th>
+                    <th md-column md-order-by="{{ h.index }}" ng-repeat="h in source.ts.header"><span>{{ h.dataKey.label }}</span></th>
+                </tr>
+                </thead>
+                <tbody md-body>
+                <tr md-row ng-repeat="row in source.ts.data">
+                    <td ng-show="$index > 0 || ($index === 0 && vm.showTimestamp)" md-cell ng-repeat="d in row track by $index" ng-style="vm.cellStyle(source, $index, d)" ng-bind-html="vm.cellContent(source, $index, row, d)">
+                    </td>
+                </tr>
+                </tbody>
+            </table>
+        </md-table-container>
+        <md-table-pagination md-limit="source.query.limit" md-limit-options="[5, 10, 15]"
+                             md-page="source.query.page" md-total="{{source.ts.count}}"
+                             md-on-paginate="vm.onPaginate(source)" md-page-select>
+        </md-table-pagination>
+    </md-tab>
+</md-tabs>
\ No newline at end of file
diff --git a/ui/src/scss/main.scss b/ui/src/scss/main.scss
index 9f89aba..ab9b0d7 100644
--- a/ui/src/scss/main.scss
+++ b/ui/src/scss/main.scss
@@ -236,6 +236,32 @@ div {
   }
 }
 
+pre.tb-highlight {
+  background-color: #f7f7f7;
+  display: block;
+  margin: 20px 0;
+  padding: 15px;
+  overflow-x: auto;
+  code {
+    padding: 0;
+    color: #303030;
+    font-family: monospace;
+    display: inline-block;
+    box-sizing: border-box;
+    vertical-align: bottom;
+    font-size: 16px;
+    font-weight: bold;
+  }
+}
+
+.tb-notice {
+  background-color: #f7f7f7;
+  padding: 15px;
+  border: 1px solid #ccc;
+  font-size: 16px;
+}
+
+
 /***********************
  * Flow
  ***********************/
diff --git a/ui/src/svg/logo_title_white.svg b/ui/src/svg/logo_title_white.svg
index fff3681..3e6d570 100644
--- a/ui/src/svg/logo_title_white.svg
+++ b/ui/src/svg/logo_title_white.svg
@@ -1,40 +1,35 @@
-<svg id="svg2" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="320" width="1740" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 1740 320.00002">
+<svg id="svg2" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="320" width="1543.4" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 1543.4268 320.00026">
  <g id="layer1" transform="translate(0 -732.36)">
-  <g id="g4652" stroke-width="28">
-   <g stroke-width="28">
-    <g id="g5204-0" transform="matrix(-.66287 .69913 -.66371 -.70001 863.46 1410.6)">
-     <g id="g5175-6" transform="translate(2.5254 3.0305)"></g>
-     <g id="g5175-9-8" transform="translate(41.25 -30.543)" stroke-width="28"></g>
-    </g>
-    <g id="g5175-6-35" transform="matrix(.57657 .52719 -.57729 .52786 584.63 346.6)"></g>
-    <g id="g5204-0-5-0" transform="matrix(.66287 -.69913 .66371 .70001 -543.46 380.47)">
-     <g id="g5175-6-35-9" transform="translate(2.5254 3.0305)"></g>
-     <g id="g5175-9-8-2-6" transform="translate(41.25 -30.543)" stroke-width="28"></g>
-    </g>
+  <g stroke-width="28">
+   <g id="g5204-0" transform="matrix(-.66287 .69913 -.66371 -.70001 863.46 1410.6)">
+    <g id="g5175-6" transform="translate(2.5254 3.0305)"></g>
+    <g id="g5175-9-8" transform="translate(41.25 -30.543)" stroke-width="28"></g>
+   </g>
+   <g id="g5175-6-35" transform="matrix(.57657 .52719 -.57729 .52786 584.63 346.6)"></g>
+   <g id="g5204-0-5-0" transform="matrix(.66287 -.69913 .66371 .70001 -543.46 380.47)">
+    <g id="g5175-6-35-9" transform="translate(2.5254 3.0305)"></g>
+    <g id="g5175-9-8-2-6" transform="translate(41.25 -30.543)" stroke-width="28"></g>
    </g>
-   <g id="text4931">
-    <g id="g4983" fill="#fff">
-     <g id="g5263">
-      <path id="path5235" d="m413.4 820.82v27.185h20.959v14.838h-20.959v69.623q0 6.7444 2.8015 10.168 2.8015 3.3203 9.5459 3.3203 3.3203 0 9.1309-1.2451v15.564q-7.5745 2.0752-14.734 2.0752-12.866 0-19.403-7.782-6.5369-7.782-6.5369-22.101v-69.623h-20.441v-14.838h20.441v-27.185h19.196z"/>
-      <path id="path5237" d="m475.86 861.6q12.762-15.668 33.203-15.668 35.59 0 35.901 40.155v74.188h-19.196v-74.292q-0.10376-12.14-5.603-17.95-5.3955-5.8106-16.913-5.8106-9.3384 0-16.394 4.9805-7.0557 4.9805-10.999 13.074v79.999h-19.196v-159.38h19.196v60.699z"/>
-      <path id="path5239" d="m594.56 960.27h-19.196v-112.27h19.196v112.27zm-20.75-142.05q0-4.6692 2.8015-7.8857 2.9053-3.2166 8.5083-3.2166t8.5083 3.2166 2.9053 7.8857q0 4.6692-2.9053 7.782t-8.5083 3.1128-8.5083-3.1128q-2.8015-3.1128-2.8015-7.782z"/>
-      <path id="path5241" d="m643.33 848 0.62256 14.111q12.866-16.187 33.618-16.187 35.59 0 35.901 40.155v74.188h-19.196v-74.292q-0.10376-12.14-5.603-17.95-5.3955-5.8106-16.913-5.8106-9.3384 0-16.394 4.9805-7.0557 4.9805-10.999 13.074v79.999h-19.196v-112.27h18.158z"/>
-      <path id="path5243" d="m738.06 903.2q0-26.251 12.14-41.711 12.14-15.564 32.166-15.564 20.544 0 32.062 14.526l0.93384-12.451h17.535v109.57q0 21.79-12.97 34.344-12.866 12.555-34.656 12.555-12.14 0-23.761-5.1879-11.621-5.188-17.743-14.215l9.9609-11.517q12.347 15.253 30.194 15.253 14.008 0 21.79-7.8858 7.8857-7.8857 7.8857-22.205v-9.6497q-11.517 13.281-31.439 13.281-19.714 0-31.958-15.875-12.14-15.875-12.14-43.268zm19.299 2.179q0 18.988 7.782 29.883 7.782 10.791 21.79 10.791 18.158 0 26.666-16.498v-51.257q-8.8196-16.083-26.459-16.083-14.008 0-21.893 10.895-7.8857 10.895-7.8857 32.269z"/>
-      <path id="path5245" d="m927.11 930.49q0-7.782-5.9143-12.036-5.8106-4.3579-20.441-7.4707-14.526-3.1128-23.138-7.4707-8.5083-4.3579-12.659-10.376-4.0466-6.0181-4.0466-14.319 0-13.8 11.621-23.346 11.725-9.5459 29.883-9.5459 19.092 0 30.92 9.8572 11.932 9.8572 11.932 25.214h-19.299q0-7.8857-6.7444-13.593-6.6406-5.7068-16.809-5.7068-10.48 0-16.394 4.5654-5.9143 4.5654-5.9143 11.932 0 6.9519 5.4993 10.48 5.4993 3.5278 19.818 6.7444 14.423 3.2166 23.346 7.6782 8.9233 4.4617 13.177 10.791 4.3579 6.2256 4.3579 15.253 0 15.045-12.036 24.176-12.036 9.0271-31.232 9.0271-13.489 0-23.865-4.7729-10.376-4.773-16.29-13.281-5.8105-8.6121-5.8105-18.573h19.196q0.5188 9.6497 7.6782 15.356 7.2632 5.603 19.092 5.603 10.895 0 17.432-4.3579 6.6406-4.4617 6.6406-11.829z"/>
-      <path id="path5247" d="m1066.4 905.38q0 25.732-11.829 41.4-11.829 15.564-31.75 15.564-21.271 0-32.892-15.045l-0.93384 12.97h-17.639v-159.38h19.196v59.454q11.621-14.423 32.062-14.423t32.062 15.46q11.725 15.46 11.725 42.334v1.6602zm-19.196-2.179q0-19.611-7.5745-30.298-7.5744-10.687-21.79-10.687-18.988 0-27.289 17.639v48.56q8.8196 17.639 27.496 17.639 13.8 0 21.478-10.687 7.6782-10.687 7.6782-32.166z"/>
-      <path id="path5249" d="m1085.3 903.1q0-16.498 6.4331-29.675 6.5369-13.177 18.054-20.337 11.621-7.1594 26.459-7.1594 22.931 0 37.042 15.875 14.215 15.875 14.215 42.23v1.3489q0 16.394-6.3293 29.468-6.2256 12.97-17.95 20.233-11.621 7.2632-26.77 7.2632-22.827 0-37.042-15.875-14.111-15.875-14.111-42.023v-1.3489zm19.299 2.2827q0 18.677 8.6121 29.987 8.7158 11.31 23.242 11.31 14.63 0 23.242-11.414 8.6121-11.517 8.6121-32.166 0-18.469-8.8196-29.883-8.7158-11.517-23.242-11.517-14.215 0-22.931 11.31-8.7158 11.31-8.7158 32.373z"/>
-      <path id="path5251" d="m1280.9 960.27q-1.6601-3.3203-2.6977-11.829-13.385 13.904-31.958 13.904-16.602 0-27.289-9.3384-10.584-9.4421-10.584-23.865 0-17.535 13.281-27.185 13.385-9.7534 37.561-9.7534h18.677v-8.8196q0-10.065-6.0181-15.979-6.018-6.0181-17.743-6.0181-10.272 0-17.224 5.188-6.9519 5.188-6.9519 12.555h-19.299q0-8.4045 5.9143-16.187 6.0181-7.8857 16.186-12.451 10.272-4.5654 22.516-4.5654 19.403 0 30.402 9.7534 10.998 9.6496 11.414 26.666v51.672q0 15.46 3.9428 24.591v1.6602h-20.129zm-31.854-14.63q9.0271 0 17.12-4.6692 8.0932-4.6692 11.725-12.14v-23.035h-15.045q-35.278 0-35.278 20.648 0 9.0271 6.0181 14.111 6.0181 5.0842 15.46 5.0842z"/>
-      <path id="path5253" d="m1381.7 865.23q-4.3579-0.72631-9.4422-0.72631-18.884 0-25.629 16.083v79.688h-19.196v-112.27h18.677l0.3113 12.97q9.4421-15.045 26.77-15.045 5.603 0 8.5083 1.4526v17.847z"/>
-      <path id="path5255" d="m1394.5 903.2q0-25.836 12.244-41.504 12.244-15.771 32.062-15.771 19.714 0 31.232 13.489v-58.521h19.196v159.38h-17.639l-0.9338-12.036q-11.517 14.111-32.062 14.111-19.507 0-31.854-15.979-12.244-15.979-12.244-41.711v-1.4526zm19.196 2.179q0 19.092 7.8858 29.883 7.8857 10.791 21.79 10.791 18.262 0 26.666-16.394v-51.569q-8.6121-15.875-26.459-15.875-14.111 0-21.997 10.895-7.8858 10.895-7.8858 32.269z"/>
-      <path id="path5257" d="m1519.6 950.21q0-4.9805 2.9053-8.3008 3.009-3.3203 8.9233-3.3203t8.9234 3.3203q3.1127 3.3203 3.1127 8.3008 0 4.773-3.1127 7.9895-3.0091 3.2166-8.9234 3.2166t-8.9233-3.2166q-2.9053-3.2166-2.9053-7.9895z"/>
-      <path id="path5259" d="m1596 960.27h-19.196v-112.27h19.196v112.27zm-20.752-142.05q0-4.6692 2.8015-7.8857 2.9053-3.2166 8.5083-3.2166t8.5083 3.2166 2.9053 7.8857q0 4.6692-2.9053 7.782t-8.5083 3.1128-8.5083-3.1128q-2.8015-3.1128-2.8015-7.782z"/>
-      <path id="path5261" d="m1621.6 903.1q0-16.498 6.4332-29.675 6.5368-13.177 18.054-20.337 11.621-7.1594 26.459-7.1594 22.931 0 37.042 15.875 14.215 15.875 14.215 42.23v1.3489q0 16.394-6.3293 29.468-6.2256 12.97-17.95 20.233-11.621 7.2632-26.77 7.2632-22.827 0-37.042-15.875-14.111-15.875-14.111-42.023v-1.3489zm19.299 2.2827q0 18.677 8.612 29.987 8.7158 11.31 23.242 11.31 14.63 0 23.242-11.414 8.6121-11.517 8.6121-32.166 0-18.469-8.8196-29.883-8.7158-11.517-23.242-11.517-14.215 0-22.931 11.31-8.7158 11.31-8.7158 32.373z"/>
-     </g>
-     <g id="g4241" transform="translate(-.000061238 .000078147)" stroke-width="28">
-      <path id="path4278" style="color-rendering:auto;text-decoration-color:#000000;color:#000000;isolation:auto;mix-blend-mode:normal;shape-rendering:auto;solid-color:#000000;block-progression:tb;text-decoration-line:none;text-decoration-style:solid;image-rendering:auto;white-space:normal;text-indent:0;text-transform:none" d="m151.13 732.36c-28.363 0-54.915 7.9153-77.613 21.537-6.4723-5.2623-14.605-8.1915-23.067-8.1937a8.7661 8.7661 0 0 0 -0.0035 0c-20.154 0.00073-36.679 16.528-36.678 36.682a8.7661 8.7661 0 0 0 0 0.0106c0.0099 8.4147 2.9267 16.483 8.1033 22.927-13.83 22.83-21.87 49.58-21.87 78.17a8.7661 8.7661 0 1 0 17.531 0c0-24.702 6.7193-47.748 18.378-67.574 4.5663 1.9846 9.4727 3.1496 14.519 3.1573a8.7661 8.7661 0 0 0 0.01241 0c20.155 0.00062 36.683-16.527 36.682-36.682a8.7661 8.7661 0 0 0 0 -0.004c-0.0016-4.9994-1.1387-9.8628-3.0828-14.397 19.717-11.484 42.585-18.095 67.085-18.095a8.7661 8.7661 0 1 0 0 -17.531zm-100.69 30.88c5.9129 0.002 11.191 2.5121 14.836 7.0769a8.7661 8.7661 0 0 0 0.1826 0.21451c2.6715 3.3798 4.1326 7.5531 4.1341 11.863-0.0015 10.677-8.468 19.144-19.144 19.148-4.37-0.008-8.6011-1.5088-12-4.2546a8.7661 8.7661 0 0 0 -0.01241 -0.009c-4.514-3.6331-7.1358-9.0979-7.1442-14.893 0.0025-10.677 8.4701-19.144 19.148-19.146z"/>
-      <path id="path4486-0-9-6-9-7-6-5" style="color-rendering:auto;text-decoration-color:#000000;color:#000000;isolation:auto;mix-blend-mode:normal;shape-rendering:auto;solid-color:#000000;block-progression:tb;text-decoration-line:none;text-decoration-style:solid;image-rendering:auto;white-space:normal;text-indent:0;text-transform:none" d="m66.992 835.19c-1.492 1.5583-2.3663 3.7103-2.2576 6.0713 0.0962 2.0447 0.91322 4.0204 2.3372 5.5177 6.8051 6.8559 20.223 20.223 20.223 20.223l11.844-11.83s-12.973-12.961-20.176-20.171c-1.604-1.6325-3.7498-2.3141-6.012-2.3243-2.3583-0.0112-4.4673 0.95389-5.9592 2.5122zm32.147 19.983-36.639 36.639c-3.9751 3.976-3.9751 10.421 0 14.397l18.156 18.156 31.753 31.753 30.478 30.478c3.9758 3.9759 10.422 3.9763 14.398 0l24.791-24.791 37.914-37.914 36.639-36.639c3.9754-3.9764 3.975-10.422-0.00067-14.398l-18.63-18.63-31.75-31.76-30.01-30c-3.976-3.9751-10.421-3.9751-14.397 0l-24.79 24.79-37.91 37.91zm37.911-37.91s-12.973-12.961-20.176-20.171c-1.604-1.6325-3.7498-2.3141-6.012-2.3243-4.7166-0.0226-8.4341 3.8616-8.2169 8.5834 0.0962 2.0447 0.91322 4.0204 2.3372 5.5177 6.8051 6.8559 20.223 20.223 20.223 20.223l11.844-11.83zm69.193 5.2132s12.961-12.973 20.171-20.176c1.6325-1.604 2.3141-3.7498 2.3243-6.012 0.0225-4.7166-3.8616-8.4341-8.5834-8.2169-2.0447 0.0962-4.0204 0.91322-5.5177 2.3372-6.8559 6.8051-20.223 20.223-20.223 20.223l11.83 11.844zm31.753 31.753s12.961-12.973 20.171-20.176c1.6325-1.604 2.314-3.7498 2.3243-6.012 0.0225-4.7166-3.8616-8.4341-8.5834-8.2169-2.0447 0.0962-4.0204 0.91323-5.5177 2.3372-6.8559 6.8051-20.223 20.223-20.223 20.223l11.83 11.844zm-18.009 69.667s12.973 12.961 20.178 20.169c1.604 1.6324 3.7498 2.314 6.012 2.3243 4.7166 0.0225 8.4342-3.8615 8.2169-8.5834l-0.002 0.002c-0.0962-2.0447-0.91415-4.0214-2.3382-5.5186-6.8051-6.8559-20.222-20.222-20.222-20.222l-11.844 11.83zm-37.914 37.914s12.973 12.961 20.178 20.169c1.604 1.6324 3.7498 2.314 6.012 2.3242 4.7166 0.023 8.4342-3.8615 8.2169-8.5834h-0.002c-0.0962-2.0447-0.91323-4.0205-2.3372-5.5177-6.8051-6.8559-20.223-20.223-20.223-20.223l-11.844 11.83zm-69.667-5.6871s-12.961 12.973-20.169 20.178c-1.6324 1.604-2.314 3.7498-2.3243 6.012-0.02251 4.7166 3.8615 8.4342 8.5834 8.2169h-0.0019c2.0447-0.096 4.0204-0.9132 5.5177-2.3373 6.8561-6.8048 20.223-20.223 20.223-20.223l-11.82-11.84zm-31.743-31.74s-12.961 12.973-20.169 20.178c-1.6324 1.604-2.314 3.7498-2.3243 6.012-0.02251 4.7167 3.8615 8.4342 8.5834 8.2169h-0.0019c2.0447-0.0962 4.0204-0.91322 5.5177-2.3372 6.8561-6.8047 20.223-20.223 20.223-20.223l-11.829-11.85zm87.237-90.554c1.6794-1.7064 3.9669-2.6599 6.2971-2.626 1.6628 0.0237 3.253 0.55012 4.5625 1.5097l16.499 12.1c3.2009 2.297 4.1445 6.6589 2.2308 10.312-1.9137 3.6528-6.1234 5.5243-9.9506 4.4227l6.124 23.948c1.1128 4.3517-1.564 8.9677-5.9833 10.317l-44.642 13.631 8.2456 31.883c1.1728 4.3696-1.5017 9.0445-5.9546 10.407s-8.9756-1.1106-10.068-5.5047l-10.282-39.769c-1.1265-4.3556 1.5493-8.9847 5.9759-10.338l44.661-13.637-4.1219-16.118c-2.7634 3.0643-7.2335 3.8084-10.586 1.7615-3.3531-2.0469-4.6144-6.2896-2.9861-10.047l8.1169-19.454c0.43322-1.0376 1.0673-1.9888 1.8624-2.7973z" fill-rule="evenodd"/>
-      <path id="path4278-5" style="color-rendering:auto;text-decoration-color:#000000;color:#000000;isolation:auto;mix-blend-mode:normal;shape-rendering:auto;solid-color:#000000;block-progression:tb;text-decoration-line:none;text-decoration-style:solid;image-rendering:auto;white-space:normal;text-indent:0;text-transform:none" d="m168.87 1052.4c28.363 0 54.915-7.9155 77.614-21.538 6.4724 5.2624 14.605 8.1917 23.067 8.1939a8.7662 8.7662 0 0 0 0.004 0c20.155-0.0007 36.68-16.528 36.679-36.682a8.7662 8.7662 0 0 0 0 -0.011c-0.01-8.4149-2.9267-16.484-8.1034-22.927 13.825-22.82 21.866-49.572 21.866-78.162a8.7662 8.7662 0 1 0 -17.531 0c0 24.703-6.7194 47.749-18.378 67.575-4.5664-1.9846-9.4728-3.1496-14.519-3.1573a8.7662 8.7662 0 0 0 -0.0124 0c-20.155-0.00062-36.683 16.527-36.682 36.682 0.002 4.9994 1.1387 9.8628 3.0829 14.397-19.717 11.484-42.586 18.095-67.086 18.095a8.7662 8.7662 0 1 0 0 17.531zm100.69-30.875c-5.913 0-11.191-2.5122-14.836-7.0769a8.7662 8.7662 0 0 0 -0.18261 -0.2146c-2.6715-3.3799-4.1327-7.5531-4.1341-11.863 0.002-10.677 8.4681-19.144 19.144-19.148 4.37 0.008 8.6012 1.5088 12 4.2547a8.7662 8.7662 0 0 0 0.0124 0.009c4.5141 3.6332 7.1359 9.098 7.1443 14.893-0.003 10.677-8.4702 19.145-19.148 19.146z"/>
-     </g>
+  </g>
+  <g id="text4279" transform="translate(0 732.36)" fill="#305680">
+   <g id="g9352" fill="#fff">
+    <g id="g4241" transform="translate(-.000061238 -732.36)">
+     <path id="path4278" style="color-rendering:auto;text-decoration-color:#000000;color:#000000;isolation:auto;mix-blend-mode:normal;shape-rendering:auto;solid-color:#000000;block-progression:tb;text-decoration-line:none;text-decoration-style:solid;image-rendering:auto;white-space:normal;text-indent:0;text-transform:none" d="m151.13 732.36c-28.363 0-54.915 7.9153-77.613 21.537-6.4723-5.2623-14.605-8.1915-23.067-8.1937a8.7661 8.7661 0 0 0 -0.0035 0c-20.154 0.00073-36.679 16.528-36.678 36.682a8.7661 8.7661 0 0 0 0 0.0106c0.0099 8.4147 2.9267 16.483 8.1033 22.927-13.83 22.83-21.87 49.58-21.87 78.17a8.7661 8.7661 0 1 0 17.531 0c0-24.702 6.7193-47.748 18.378-67.574 4.5663 1.9846 9.4727 3.1496 14.519 3.1573a8.7661 8.7661 0 0 0 0.01241 0c20.155 0.00062 36.683-16.527 36.682-36.682a8.7661 8.7661 0 0 0 0 -0.004c-0.0016-4.9994-1.1387-9.8628-3.0828-14.397 19.717-11.484 42.585-18.095 67.085-18.095a8.7661 8.7661 0 1 0 0 -17.531zm-100.69 30.88c5.9129 0.002 11.191 2.5121 14.836 7.0769a8.7661 8.7661 0 0 0 0.1826 0.21451c2.6715 3.3798 4.1326 7.5531 4.1341 11.863-0.0015 10.677-8.468 19.144-19.144 19.148-4.37-0.008-8.6011-1.5088-12-4.2546a8.7661 8.7661 0 0 0 -0.01241 -0.009c-4.514-3.6331-7.1358-9.0979-7.1442-14.893 0.0025-10.677 8.4701-19.144 19.148-19.146z"/>
+     <path id="path4486-0-9-6-9-7-6-5" style="color-rendering:auto;text-decoration-color:#000000;color:#000000;isolation:auto;mix-blend-mode:normal;shape-rendering:auto;solid-color:#000000;block-progression:tb;text-decoration-line:none;text-decoration-style:solid;image-rendering:auto;white-space:normal;text-indent:0;text-transform:none" d="m66.992 835.19c-1.492 1.5583-2.3663 3.7103-2.2576 6.0713 0.0962 2.0447 0.91322 4.0204 2.3372 5.5177 6.8051 6.8559 20.223 20.223 20.223 20.223l11.844-11.83s-12.973-12.961-20.176-20.171c-1.604-1.6325-3.7498-2.3141-6.012-2.3243-2.3583-0.0112-4.4673 0.95389-5.9592 2.5122zm32.147 19.983-36.639 36.639c-3.9751 3.976-3.9751 10.421 0 14.397l18.156 18.156 31.753 31.753 30.478 30.478c3.9758 3.9759 10.422 3.9763 14.398 0l24.791-24.791 37.914-37.914 36.639-36.639c3.9754-3.9764 3.975-10.422-0.00067-14.398l-18.63-18.63-31.75-31.76-30.01-30c-3.976-3.9751-10.421-3.9751-14.397 0l-24.79 24.79-37.91 37.91zm37.911-37.91s-12.973-12.961-20.176-20.171c-1.604-1.6325-3.7498-2.3141-6.012-2.3243-4.7166-0.0226-8.4341 3.8616-8.2169 8.5834 0.0962 2.0447 0.91322 4.0204 2.3372 5.5177 6.8051 6.8559 20.223 20.223 20.223 20.223l11.844-11.83zm69.193 5.2132s12.961-12.973 20.171-20.176c1.6325-1.604 2.3141-3.7498 2.3243-6.012 0.0225-4.7166-3.8616-8.4341-8.5834-8.2169-2.0447 0.0962-4.0204 0.91322-5.5177 2.3372-6.8559 6.8051-20.223 20.223-20.223 20.223l11.83 11.844zm31.753 31.753s12.961-12.973 20.171-20.176c1.6325-1.604 2.314-3.7498 2.3243-6.012 0.0225-4.7166-3.8616-8.4341-8.5834-8.2169-2.0447 0.0962-4.0204 0.91323-5.5177 2.3372-6.8559 6.8051-20.223 20.223-20.223 20.223l11.83 11.844zm-18.009 69.667s12.973 12.961 20.178 20.169c1.604 1.6324 3.7498 2.314 6.012 2.3243 4.7166 0.0225 8.4342-3.8615 8.2169-8.5834l-0.002 0.002c-0.0962-2.0447-0.91415-4.0214-2.3382-5.5186-6.8051-6.8559-20.222-20.222-20.222-20.222l-11.844 11.83zm-37.914 37.914s12.973 12.961 20.178 20.169c1.604 1.6324 3.7498 2.314 6.012 2.3242 4.7166 0.023 8.4342-3.8615 8.2169-8.5834h-0.002c-0.0962-2.0447-0.91323-4.0205-2.3372-5.5177-6.8051-6.8559-20.223-20.223-20.223-20.223l-11.844 11.83zm-69.667-5.6871s-12.961 12.973-20.169 20.178c-1.6324 1.604-2.314 3.7498-2.3243 6.012-0.02251 4.7166 3.8615 8.4342 8.5834 8.2169h-0.0019c2.0447-0.096 4.0204-0.9132 5.5177-2.3373 6.8561-6.8048 20.223-20.223 20.223-20.223l-11.82-11.84zm-31.743-31.74s-12.961 12.973-20.169 20.178c-1.6324 1.604-2.314 3.7498-2.3243 6.012-0.02251 4.7167 3.8615 8.4342 8.5834 8.2169h-0.0019c2.0447-0.0962 4.0204-0.91322 5.5177-2.3372 6.8561-6.8047 20.223-20.223 20.223-20.223l-11.829-11.85zm87.237-90.554c1.6794-1.7064 3.9669-2.6599 6.2971-2.626 1.6628 0.0237 3.253 0.55012 4.5625 1.5097l16.499 12.1c3.2009 2.297 4.1445 6.6589 2.2308 10.312-1.9137 3.6528-6.1234 5.5243-9.9506 4.4227l6.124 23.948c1.1128 4.3517-1.564 8.9677-5.9833 10.317l-44.642 13.631 8.2456 31.883c1.1728 4.3696-1.5017 9.0445-5.9546 10.407s-8.9756-1.1106-10.068-5.5047l-10.282-39.769c-1.1265-4.3556 1.5493-8.9847 5.9759-10.338l44.661-13.637-4.1219-16.118c-2.7634 3.0643-7.2335 3.8084-10.586 1.7615-3.3531-2.0469-4.6144-6.2896-2.9861-10.047l8.1169-19.454c0.43322-1.0376 1.0673-1.9888 1.8624-2.7973z" fill-rule="evenodd"/>
+     <path id="path4278-5" style="color-rendering:auto;text-decoration-color:#000000;color:#000000;isolation:auto;mix-blend-mode:normal;shape-rendering:auto;solid-color:#000000;block-progression:tb;text-decoration-line:none;text-decoration-style:solid;image-rendering:auto;white-space:normal;text-indent:0;text-transform:none" d="m168.87 1052.4c28.363 0 54.915-7.9155 77.614-21.538 6.4724 5.2624 14.605 8.1917 23.067 8.1939a8.7662 8.7662 0 0 0 0.004 0c20.155-0.0007 36.68-16.528 36.679-36.682a8.7662 8.7662 0 0 0 0 -0.011c-0.01-8.4149-2.9267-16.484-8.1034-22.927 13.825-22.82 21.866-49.572 21.866-78.162a8.7662 8.7662 0 1 0 -17.531 0c0 24.703-6.7194 47.749-18.378 67.575-4.5664-1.9846-9.4728-3.1496-14.519-3.1573a8.7662 8.7662 0 0 0 -0.0124 0c-20.155-0.00062-36.683 16.527-36.682 36.682 0.002 4.9994 1.1387 9.8628 3.0829 14.397-19.717 11.484-42.586 18.095-67.086 18.095a8.7662 8.7662 0 1 0 0 17.531zm100.69-30.875c-5.913 0-11.191-2.5122-14.836-7.0769a8.7662 8.7662 0 0 0 -0.18261 -0.2146c-2.6715-3.3799-4.1327-7.5531-4.1341-11.863 0.002-10.677 8.4681-19.144 19.144-19.148 4.37 0.008 8.6012 1.5088 12 4.2547a8.7662 8.7662 0 0 0 0.0124 0.009c4.5141 3.6332 7.1359 9.098 7.1443 14.893-0.003 10.677-8.4702 19.145-19.148 19.146z"/>
+    </g>
+    <g id="g8299">
+     <path id="path4345" d="m477.92 93.229h-48.56v134.68h-19.818v-134.68h-48.456v-16.394h116.83v16.394z"/>
+     <path id="path4347" d="m516.72 129.23q12.762-15.668 33.203-15.668 35.59 0 35.901 40.155v74.188h-19.196v-74.292q-0.10376-12.14-5.603-17.95-5.3955-5.8105-16.913-5.8105-9.3384 0-16.394 4.9805-7.0557 4.9805-10.999 13.074v79.999h-19.196v-159.38h19.196v60.699z"/>
+     <path id="path4349" d="m635.43 227.91h-19.196v-112.27h19.196v112.27zm-20.76-142.05q0-4.6692 2.8015-7.8857 2.9053-3.2166 8.5083-3.2166t8.5083 3.2166 2.9053 7.8857q0 4.6692-2.9053 7.782t-8.5083 3.1128-8.5083-3.1128q-2.8015-3.1128-2.8015-7.782z"/>
+     <path id="path4351" d="m684.19 115.64 0.62256 14.111q12.866-16.187 33.618-16.187 35.59 0 35.901 40.155v74.188h-19.196v-74.292q-0.10376-12.14-5.603-17.95-5.3955-5.8105-16.913-5.8105-9.3384 0-16.394 4.9805-7.0557 4.9805-10.999 13.074v79.999h-19.196v-112.27h18.158z"/>
+     <path id="path4353" d="m778.92 170.84q0-26.251 12.14-41.711 12.14-15.564 32.166-15.564 20.544 0 32.062 14.526l0.93384-12.451h17.535v109.57q0 21.79-12.97 34.344-12.866 12.555-34.656 12.555-12.14 0-23.761-5.188t-17.743-14.215l9.9609-11.517q12.347 15.253 30.194 15.253 14.008 0 21.79-7.8857 7.8857-7.8858 7.8857-22.205v-9.6497q-11.517 13.281-31.439 13.281-19.714 0-31.958-15.875-12.14-15.875-12.14-43.268zm19.299 2.179q0 18.988 7.782 29.883 7.782 10.791 21.79 10.791 18.158 0 26.666-16.498v-51.257q-8.8196-16.083-26.459-16.083-14.008 0-21.893 10.895-7.8858 10.895-7.8858 32.269z"/>
+     <path id="path4355" d="m967.98 198.13q0-7.782-5.9143-12.036-5.8106-4.3579-20.441-7.4707-14.526-3.1128-23.138-7.4707-8.5083-4.3579-12.659-10.376-4.0466-6.0181-4.0466-14.319 0-13.8 11.621-23.346 11.725-9.5459 29.883-9.5459 19.092 0 30.92 9.8572 11.932 9.8572 11.932 25.214h-19.299q0-7.8858-6.7444-13.593-6.6406-5.7068-16.809-5.7068-10.48 0-16.394 4.5654-5.9143 4.5654-5.9143 11.932 0 6.9519 5.4993 10.48 5.4993 3.5278 19.818 6.7444 14.423 3.2166 23.346 7.6782 8.9233 4.4617 13.177 10.791 4.3579 6.2256 4.3579 15.253 0 15.045-12.036 24.176-12.036 9.0271-31.232 9.0271-13.489 0-23.865-4.773t-16.29-13.281q-5.8105-8.6121-5.8105-18.573h19.196q0.5188 9.6497 7.6782 15.356 7.2632 5.603 19.092 5.603 10.895 0 17.432-4.3579 6.6406-4.4617 6.6406-11.829z"/>
+     <path id="path4357" d="m1015.2 227.91v-151.07h49.39q24.591 0 36.938 10.168 12.451 10.168 12.451 30.09 0 10.583-6.0181 18.781-6.018 8.0933-16.394 12.555 12.244 3.4241 19.299 13.074 7.1594 9.5459 7.1594 22.827 0 20.337-13.178 31.958t-37.25 11.621h-52.399zm19.922-70.66v54.37h32.892q13.904 0 21.893-7.1594 8.0933-7.2632 8.0933-19.922 0-27.289-29.675-27.289h-33.203zm0-15.979h30.09q13.074 0 20.856-6.5369 7.8858-6.5369 7.8858-17.743 0-12.451-7.2632-18.054-7.2632-5.7068-22.101-5.7068h-29.468v48.041z"/>
+     <path id="path4359" d="m1139.5 170.74q0-16.498 6.4331-29.675 6.5369-13.177 18.054-20.337 11.621-7.1594 26.459-7.1594 22.931 0 37.042 15.875 14.215 15.875 14.215 42.23v1.3489q0 16.394-6.3294 29.468-6.2256 12.97-17.95 20.233-11.621 7.2632-26.77 7.2632-22.827 0-37.042-15.875-14.1-15.88-14.1-42.02v-1.3489zm19.299 2.2827q0 18.677 8.6121 29.987 8.7158 11.31 23.242 11.31 14.63 0 23.242-11.414 8.6121-11.517 8.6121-32.166 0-18.469-8.8196-29.883-8.7158-11.517-23.242-11.517-14.215 0-22.931 11.31-8.7158 11.31-8.7158 32.373z"/>
+     <path id="path4361" d="m1335.1 227.91q-1.6602-3.3203-2.6978-11.829-13.385 13.904-31.958 13.904-16.602 0-27.289-9.3384-10.584-9.4421-10.584-23.865 0-17.535 13.281-27.185 13.385-9.7534 37.561-9.7534h18.677v-8.8196q0-10.065-6.018-15.979-6.0181-6.0181-17.743-6.0181-10.272 0-17.224 5.188-6.9519 5.188-6.9519 12.555h-19.299q0-8.4045 5.9143-16.187 6.0181-7.8858 16.186-12.451 10.272-4.5654 22.516-4.5654 19.403 0 30.402 9.7534 10.998 9.6497 11.414 26.666v51.672q0 15.46 3.9429 24.591v1.6602h-20.129zm-31.854-14.63q9.0271 0 17.12-4.6692 8.0932-4.6692 11.725-12.14v-23.035h-15.045q-35.278 0-35.278 20.648 0 9.0271 6.0181 14.111 6.0181 5.0842 15.46 5.0842z"/>
+     <path id="path4363" d="m1435.8 132.87q-4.3579-0.72632-9.4422-0.72632-18.884 0-25.629 16.083v79.688h-19.196v-112.27h18.677l0.3113 12.97q9.4421-15.045 26.77-15.045 5.603 0 8.5083 1.4526v17.847z"/>
+     <path id="path4365" d="m1448.7 170.84q0-25.836 12.244-41.504 12.244-15.771 32.062-15.771 19.714 0 31.232 13.489v-58.521h19.196v159.38h-17.639l-0.9339-12.036q-11.517 14.111-32.062 14.111-19.507 0-31.854-15.979-12.244-15.979-12.244-41.711v-1.4526zm19.196 2.179q0 19.092 7.8858 29.883 7.8857 10.791 21.79 10.791 18.262 0 26.666-16.394v-51.569q-8.6121-15.875-26.459-15.875-14.111 0-21.997 10.895-7.8858 10.895-7.8858 32.269z"/>
     </g>
    </g>
   </g>
diff --git a/ui/webpack.config.dev.js b/ui/webpack.config.dev.js
index c669ec5..fa019d3 100644
--- a/ui/webpack.config.dev.js
+++ b/ui/webpack.config.dev.js
@@ -60,6 +60,7 @@ module.exports = {
             allChunks: true,
         }),
         new webpack.DefinePlugin({
+            THINGSBOARD_VERSION: JSON.stringify(require('./package.json').version),
             '__DEVTOOLS__': false,
             'process.env': {
                 NODE_ENV: JSON.stringify('development'),
diff --git a/ui/webpack.config.prod.js b/ui/webpack.config.prod.js
index 09374fb..a746488 100644
--- a/ui/webpack.config.prod.js
+++ b/ui/webpack.config.prod.js
@@ -58,6 +58,7 @@ module.exports = {
             allChunks: true,
         }),
         new webpack.DefinePlugin({
+            THINGSBOARD_VERSION: JSON.stringify(require('./package.json').version),
             '__DEVTOOLS__': false,
             'process.env': {
                 NODE_ENV: JSON.stringify('production'),