Commit 13a8232d7c8f60e5cdf8c8c4e70153248afbaee3

Authored by volodymyr-babak
2 parents 69546925 f78b8e55

Merge github.com:thingsboard/thingsboard

Showing 52 changed files with 1211 additions and 297 deletions
... ... @@ -19,12 +19,18 @@ import lombok.extern.slf4j.Slf4j;
19 19 import org.eclipse.paho.client.mqttv3.*;
20 20 import org.junit.Assert;
21 21 import org.junit.Before;
  22 +import org.junit.Ignore;
22 23 import org.junit.Test;
  24 +import org.springframework.http.HttpStatus;
  25 +import org.springframework.http.ResponseEntity;
  26 +import org.springframework.web.client.HttpClientErrorException;
23 27 import org.thingsboard.client.tools.RestClient;
24 28 import org.thingsboard.server.common.data.Device;
25 29 import org.thingsboard.server.common.data.security.DeviceCredentials;
26 30 import org.thingsboard.server.mqtt.AbstractFeatureIntegrationTest;
27 31
  32 +import java.util.UUID;
  33 +
28 34 import static org.junit.Assert.assertEquals;
29 35 import static org.junit.Assert.assertNotNull;
30 36
... ... @@ -40,28 +46,88 @@ public class MqttServerSideRpcIntegrationTest extends AbstractFeatureIntegration
40 46 private static final String USERNAME = "tenant@thingsboard.org";
41 47 private static final String PASSWORD = "tenant";
42 48
43   - private Device savedDevice;
44   -
45   - private String accessToken;
46 49 private RestClient restClient;
47 50
48 51 @Before
49 52 public void beforeTest() throws Exception {
50 53 restClient = new RestClient(BASE_URL);
51 54 restClient.login(USERNAME, PASSWORD);
  55 + }
52 56
  57 + @Test
  58 + public void testServerMqttOneWayRpc() throws Exception {
53 59 Device device = new Device();
54   - device.setName("Test Server-Side RPC Device");
55   - savedDevice = restClient.getRestTemplate().postForEntity(BASE_URL + "/api/device", device, Device.class).getBody();
56   - DeviceCredentials deviceCredentials =
57   - restClient.getRestTemplate().getForEntity(BASE_URL + "/api/device/" + savedDevice.getId().getId().toString() + "/credentials", DeviceCredentials.class).getBody();
  60 + device.setName("Test One-Way Server-Side RPC");
  61 + Device savedDevice = getSavedDevice(device);
  62 + DeviceCredentials deviceCredentials = getDeviceCredentials(savedDevice);
58 63 assertEquals(savedDevice.getId(), deviceCredentials.getDeviceId());
59   - accessToken = deviceCredentials.getCredentialsId();
  64 + String accessToken = deviceCredentials.getCredentialsId();
60 65 assertNotNull(accessToken);
  66 +
  67 + String clientId = MqttAsyncClient.generateClientId();
  68 + MqttAsyncClient client = new MqttAsyncClient(MQTT_URL, clientId);
  69 +
  70 + MqttConnectOptions options = new MqttConnectOptions();
  71 + options.setUserName(accessToken);
  72 + client.connect(options);
  73 + Thread.sleep(3000);
  74 + client.subscribe("v1/devices/me/rpc/request/+", 1);
  75 + client.setCallback(new TestMqttCallback(client));
  76 +
  77 + String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}";
  78 + String deviceId = savedDevice.getId().getId().toString();
  79 + ResponseEntity result = restClient.getRestTemplate().postForEntity(BASE_URL + "api/plugins/rpc/oneway/" + deviceId, setGpioRequest, String.class);
  80 + Assert.assertEquals(HttpStatus.OK, result.getStatusCode());
  81 + Assert.assertNull(result.getBody());
  82 + }
  83 +
  84 + @Test
  85 + public void testServerMqttOneWayRpcDeviceOffline() throws Exception {
  86 + Device device = new Device();
  87 + device.setName("Test One-Way Server-Side RPC Device Offline");
  88 + Device savedDevice = getSavedDevice(device);
  89 + DeviceCredentials deviceCredentials = getDeviceCredentials(savedDevice);
  90 + assertEquals(savedDevice.getId(), deviceCredentials.getDeviceId());
  91 + String accessToken = deviceCredentials.getCredentialsId();
  92 + assertNotNull(accessToken);
  93 +
  94 + String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}";
  95 + String deviceId = savedDevice.getId().getId().toString();
  96 + try {
  97 + restClient.getRestTemplate().postForEntity(BASE_URL + "api/plugins/rpc/oneway/" + deviceId, setGpioRequest, String.class);
  98 + Assert.fail("HttpClientErrorException expected, but not encountered");
  99 + } catch (HttpClientErrorException e) {
  100 + log.error(e.getMessage(), e);
  101 + Assert.assertEquals(HttpStatus.REQUEST_TIMEOUT, e.getStatusCode());
  102 + Assert.assertEquals("408 null", e.getMessage());
  103 + }
  104 + }
  105 +
  106 + @Test
  107 + public void testServerMqttOneWayRpcDeviceDoesNotExist() throws Exception {
  108 + String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}";
  109 + String nonExistentDeviceId = UUID.randomUUID().toString();
  110 + try {
  111 + restClient.getRestTemplate().postForEntity(BASE_URL + "api/plugins/rpc/oneway/" + nonExistentDeviceId, setGpioRequest, String.class);
  112 + Assert.fail("HttpClientErrorException expected, but not encountered");
  113 + } catch (HttpClientErrorException e) {
  114 + log.error(e.getMessage(), e);
  115 + Assert.assertEquals(HttpStatus.BAD_REQUEST, e.getStatusCode());
  116 + Assert.assertEquals("400 null", e.getMessage());
  117 + }
61 118 }
62 119
63 120 @Test
64 121 public void testServerMqttTwoWayRpc() throws Exception {
  122 +
  123 + Device device = new Device();
  124 + device.setName("Test Two-Way Server-Side RPC");
  125 + Device savedDevice = getSavedDevice(device);
  126 + DeviceCredentials deviceCredentials = getDeviceCredentials(savedDevice);
  127 + assertEquals(savedDevice.getId(), deviceCredentials.getDeviceId());
  128 + String accessToken = deviceCredentials.getCredentialsId();
  129 + assertNotNull(accessToken);
  130 +
65 131 String clientId = MqttAsyncClient.generateClientId();
66 132 MqttAsyncClient client = new MqttAsyncClient(MQTT_URL, clientId);
67 133
... ... @@ -69,16 +135,63 @@ public class MqttServerSideRpcIntegrationTest extends AbstractFeatureIntegration
69 135 options.setUserName(accessToken);
70 136 client.connect(options);
71 137 Thread.sleep(3000);
72   - client.subscribe("v1/devices/me/rpc/request/+",1);
  138 + client.subscribe("v1/devices/me/rpc/request/+", 1);
73 139 client.setCallback(new TestMqttCallback(client));
74 140
75 141 String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}";
76 142 String deviceId = savedDevice.getId().getId().toString();
77   - String result = restClient.getRestTemplate().postForEntity(BASE_URL + "api/plugins/rpc/twoway/" + deviceId, setGpioRequest, String.class).getBody();
78   - log.info("Result: " + result);
  143 + String result = getStringResult(setGpioRequest, "twoway", deviceId);
79 144 Assert.assertEquals("{\"value1\":\"A\",\"value2\":\"B\"}", result);
80 145 }
81 146
  147 + @Test
  148 + public void testServerMqttTwoWayRpcDeviceOffline() throws Exception {
  149 + Device device = new Device();
  150 + device.setName("Test Two-Way Server-Side RPC Device Offline");
  151 + Device savedDevice = getSavedDevice(device);
  152 + DeviceCredentials deviceCredentials = getDeviceCredentials(savedDevice);
  153 + assertEquals(savedDevice.getId(), deviceCredentials.getDeviceId());
  154 + String accessToken = deviceCredentials.getCredentialsId();
  155 + assertNotNull(accessToken);
  156 +
  157 + String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}";
  158 + String deviceId = savedDevice.getId().getId().toString();
  159 + try {
  160 + restClient.getRestTemplate().postForEntity(BASE_URL + "api/plugins/rpc/twoway/" + deviceId, setGpioRequest, String.class);
  161 + Assert.fail("HttpClientErrorException expected, but not encountered");
  162 + } catch (HttpClientErrorException e) {
  163 + log.error(e.getMessage(), e);
  164 + Assert.assertEquals(HttpStatus.REQUEST_TIMEOUT, e.getStatusCode());
  165 + Assert.assertEquals("408 null", e.getMessage());
  166 + }
  167 + }
  168 +
  169 + @Test
  170 + public void testServerMqttTwoWayRpcDeviceDoesNotExist() throws Exception {
  171 + String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}";
  172 + String nonExistentDeviceId = UUID.randomUUID().toString();
  173 + try {
  174 + restClient.getRestTemplate().postForEntity(BASE_URL + "api/plugins/rpc/oneway/" + nonExistentDeviceId, setGpioRequest, String.class);
  175 + Assert.fail("HttpClientErrorException expected, but not encountered");
  176 + } catch (HttpClientErrorException e) {
  177 + log.error(e.getMessage(), e);
  178 + Assert.assertEquals(HttpStatus.BAD_REQUEST, e.getStatusCode());
  179 + Assert.assertEquals("400 null", e.getMessage());
  180 + }
  181 + }
  182 +
  183 + private Device getSavedDevice(Device device) {
  184 + return restClient.getRestTemplate().postForEntity(BASE_URL + "/api/device", device, Device.class).getBody();
  185 + }
  186 +
  187 + private DeviceCredentials getDeviceCredentials(Device savedDevice) {
  188 + return restClient.getRestTemplate().getForEntity(BASE_URL + "/api/device/" + savedDevice.getId().getId().toString() + "/credentials", DeviceCredentials.class).getBody();
  189 + }
  190 +
  191 + private String getStringResult(String requestData, String callType, String deviceId) {
  192 + return restClient.getRestTemplate().postForEntity(BASE_URL + "api/plugins/rpc/" + callType + "/" + deviceId, requestData, String.class).getBody();
  193 + }
  194 +
82 195 private static class TestMqttCallback implements MqttCallback {
83 196
84 197 private final MqttAsyncClient client;
... ...
... ... @@ -29,6 +29,8 @@ public class DataConstants {
29 29 public static final String SERVER_SCOPE = "SERVER_SCOPE";
30 30 public static final String SHARED_SCOPE = "SHARED_SCOPE";
31 31
  32 + public static final String[] ALL_SCOPES = {CLIENT_SCOPE, SHARED_SCOPE, SERVER_SCOPE};
  33 +
32 34 public static final String ALARM = "ALARM";
33 35 public static final String ERROR = "ERROR";
34 36 public static final String LC_EVENT = "LC_EVENT";
... ...
... ... @@ -18,6 +18,8 @@ package org.thingsboard.server.common.msg.core;
18 18 import lombok.ToString;
19 19 import org.thingsboard.server.common.msg.session.MsgType;
20 20
  21 +import java.util.Collections;
  22 +import java.util.Optional;
21 23 import java.util.Set;
22 24
23 25 @ToString
... ... @@ -28,6 +30,10 @@ public class BasicGetAttributesRequest extends BasicRequest implements GetAttrib
28 30 private final Set<String> clientKeys;
29 31 private final Set<String> sharedKeys;
30 32
  33 + public BasicGetAttributesRequest(Integer requestId) {
  34 + this(requestId, Collections.emptySet(), Collections.emptySet());
  35 + }
  36 +
31 37 public BasicGetAttributesRequest(Integer requestId, Set<String> clientKeys, Set<String> sharedKeys) {
32 38 super(requestId);
33 39 this.clientKeys = clientKeys;
... ... @@ -40,13 +46,13 @@ public class BasicGetAttributesRequest extends BasicRequest implements GetAttrib
40 46 }
41 47
42 48 @Override
43   - public Set<String> getClientAttributeNames() {
44   - return clientKeys;
  49 + public Optional<Set<String>> getClientAttributeNames() {
  50 + return Optional.of(clientKeys);
45 51 }
46 52
47 53 @Override
48   - public Set<String> getSharedAttributeNames() {
49   - return sharedKeys;
  54 + public Optional<Set<String>> getSharedAttributeNames() {
  55 + return Optional.ofNullable(sharedKeys);
50 56 }
51 57
52 58 }
... ...
... ... @@ -15,6 +15,7 @@
15 15 */
16 16 package org.thingsboard.server.common.msg.core;
17 17
  18 +import java.util.Optional;
18 19 import java.util.Set;
19 20
20 21 import org.thingsboard.server.common.msg.session.FromDeviceMsg;
... ... @@ -22,7 +23,7 @@ import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg;
22 23
23 24 public interface GetAttributesRequest extends FromDeviceRequestMsg {
24 25
25   - Set<String> getClientAttributeNames();
26   - Set<String> getSharedAttributeNames();
  26 + Optional<Set<String>> getClientAttributeNames();
  27 + Optional<Set<String>> getSharedAttributeNames();
27 28
28 29 }
... ...
... ... @@ -134,7 +134,7 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'google_maps',
134 134
135 135 INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" )
136 136 VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'route_map',
137   -'{"type":"timeseries","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".error {\n color: red;\n}\n.tb-labels {\n color: #222;\n font: 12px/1.5 \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n text-align: center;\n width: 100px;\n white-space: nowrap;\n}","controllerScript":"var map;\n\nvar routesSettings = [];\nvar routes;\nvar polylines = [];\n\nvar defaultZoomLevel;\nvar dontFitMapBounds;\n\nvar varsRegex = /\\$\\{([^\\}]*)\\}/g;\n\nvar tooltips = [];\n\nfns.init = function(containerElement, settings, datasources,\n data) {\n \n if (settings.defaultZoomLevel) {\n if (settings.defaultZoomLevel > 0 && settings.defaultZoomLevel < 21) {\n defaultZoomLevel = Math.floor(settings.defaultZoomLevel);\n }\n }\n \n dontFitMapBounds = settings.fitMapBounds === false;\n \n function procesTooltipPattern(pattern, datasource, datasourceOffset) {\n var match = varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split('':'');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith(''#'')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = datasourceOffset + n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < datasource.dataKeys.length; i++) {\n var dataKey = datasource.dataKeys[i];\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = datasourceOffset + i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = varsRegex.exec(pattern);\n }\n return replaceInfo;\n }\n\n \n var configuredRoutesSettings = settings.routesSettings;\n if (!configuredRoutesSettings) {\n configuredRoutesSettings = [];\n }\n \n var datasourceOffset = 0;\n for (var i=0;i<datasources.length;i++) {\n routesSettings[i] = {\n latKeyName: \"lat\",\n lngKeyName: \"lng\",\n showLabel: true,\n label: datasources[i].name, \n color: \"#FE7569\",\n strokeWeight: 2,\n strokeOpacity: 1.0,\n tooltipPattern: \"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${lng:7}\"\n };\n if (configuredRoutesSettings[i]) {\n routesSettings[i].latKeyName = configuredRoutesSettings[i].latKeyName || routesSettings[i].latKeyName;\n routesSettings[i].lngKeyName = configuredRoutesSettings[i].lngKeyName || routesSettings[i].lngKeyName;\n routesSettings[i].tooltipPattern = configuredRoutesSettings[i].tooltipPattern || \"<b>Latitude:</b> ${\"+routesSettings[i].latKeyName+\":7}<br/><b>Longitude:</b> ${\"+routesSettings[i].lngKeyName+\":7}\";\n \n routesSettings[i].tooltipReplaceInfo = procesTooltipPattern(routesSettings[i].tooltipPattern, datasources[i], datasourceOffset);\n \n routesSettings[i].showLabel = configuredRoutesSettings[i].showLabel !== false;\n routesSettings[i].label = configuredRoutesSettings[i].label || routesSettings[i].label;\n routesSettings[i].color = configuredRoutesSettings[i].color ? tinycolor(configuredRoutesSettings[i].color).toHexString() : routesSettings[i].color;\n routesSettings[i].strokeWeight = configuredRoutesSettings[i].strokeWeight || routesSettings[i].strokeWeight;\n routesSettings[i].strokeOpacity = typeof configuredRoutesSettings[i].strokeOpacity !== \"undefined\" ? configuredRoutesSettings[i].strokeOpacity : routesSettings[i].strokeOpacity; \n }\n datasourceOffset += datasources[i].dataKeys.length;\n }\n\n var mapId = '''' + Math.random().toString(36).substr(2, 9);\n \n function clearGlobalId() {\n if ($window.loadingGmId && $window.loadingGmId === mapId) {\n $window.loadingGmId = null;\n }\n }\n \n $window.gm_authFailure = function() {\n if ($window.loadingGmId && $window.loadingGmId === mapId) {\n $window.loadingGmId = null;\n $window.gmApiKeys[apiKey].error = ''Unable to authentificate for Google Map API.</br>Please check your API key.'';\n displayError($window.gmApiKeys[apiKey].error);\n }\n };\n \n function displayError(message) {\n $(containerElement).html(\n \"<div class=''error''>\"+ message + \"</div>\"\n );\n }\n\n var initMapFunctionName = ''initGoogleMap_'' + mapId;\n $window[initMapFunctionName] = function() {\n lazyLoad.load({ type: ''js'', path: ''https://cdn.rawgit.com/googlemaps/v3-utility-library/master/markerwithlabel/src/markerwithlabel.js'' }).then(\n function success() {\n initMap();\n },\n function fail() {\n clearGloabalId();\n $window.gmApiKeys[apiKey].error = ''Google map api load failed!</br>''+e;\n displayError($window.gmApiKeys[apiKey].error);\n }\n );\n \n }; \n \n var apiKey = settings.gmApiKey || '''';\n\n if (apiKey && apiKey.length > 0) {\n if (!$window.gmApiKeys) {\n $window.gmApiKeys = {};\n }\n if ($window.gmApiKeys[apiKey]) {\n if ($window.gmApiKeys[apiKey].error) {\n displayError($window.gmApiKeys[apiKey].error);\n } else {\n initMap();\n }\n } else {\n $window.gmApiKeys[apiKey] = {};\n var googleMapScriptRes = ''https://maps.googleapis.com/maps/api/js?key=''+apiKey+''&callback=''+initMapFunctionName;\n \n $window.loadingGmId = mapId;\n lazyLoad.load({ type: ''js'', path: googleMapScriptRes }).then(\n function success() {\n setTimeout(clearGlobalId, 2000);\n },\n function fail(e) {\n clearGloabalId();\n $window.gmApiKeys[apiKey].error = ''Google map api load failed!</br>''+e;\n displayError($window.gmApiKeys[apiKey].error);\n }\n );\n }\n } else {\n displayError(''No Google Map Api Key provided!'');\n }\n\n function initMap() {\n \n map = new google.maps.Map(containerElement, {\n scrollwheel: false,\n zoom: defaultZoomLevel || 8\n });\n\n }\n\n}\n\n\nfns.redraw = function(containerElement, width, height, data,\n timeWindow, sizeChanged) {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n \n function padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n \n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n \n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split(''.'');\n s = int - strVal[0].length;\n \n for (; i < s; ++i) {\n strVal[0] = ''0'' + strVal[0];\n }\n \n strVal = (n ? ''-'' : '''') + strVal[0] + ''.'' + strVal[1];\n }\n \n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n \n for (; i < s; ++i) {\n strVal = ''0'' + strVal;\n }\n \n strVal = (n ? ''-'' : '''') + strVal;\n }\n \n return strVal;\n } \n \n function createMarker(location, settings) {\n var pinColor = settings.color.substr(1);\n var pinImage = new google.maps.MarkerImage(\"http://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=%E2%80%A2|\" + pinColor,\n new google.maps.Size(21, 34),\n new google.maps.Point(0,0),\n new google.maps.Point(10, 34));\n var pinShadow = new google.maps.MarkerImage(\"http://chart.apis.google.com/chart?chst=d_map_pin_shadow\",\n new google.maps.Size(40, 37),\n new google.maps.Point(0, 0),\n new google.maps.Point(12, 35)); \n var marker;\n if (settings.showLabel) { \n marker = new MarkerWithLabel({\n position: location, \n map: map,\n icon: pinImage,\n shadow: pinShadow,\n labelContent: ''<b>''+settings.label+''</b>'',\n labelClass: \"tb-labels\",\n labelAnchor: new google.maps.Point(50, 55)\n }); \n } else {\n marker = new google.maps.Marker({\n position: location, \n map: map,\n icon: pinImage,\n shadow: pinShadow\n }); \n }\n \n createTooltip(marker, settings.tooltipPattern, settings.tooltipReplaceInfo);\n \n return marker; \n }\n \n function createTooltip(marker, pattern, replaceInfo) {\n var infowindow = new google.maps.InfoWindow({\n content: ''''\n });\n marker.addListener(''click'', function() {\n infowindow.open(map, marker);\n });\n tooltips.push( {\n infowindow: infowindow,\n pattern: pattern,\n replaceInfo: replaceInfo\n });\n }\n\n function createPolyline(locations, settings) {\n var polyline = new google.maps.Polyline({\n path: locations,\n strokeColor: settings.color,\n strokeOpacity: settings.strokeOpacity,\n strokeWeight: settings.strokeWeight,\n map: map\n });\n \n return polyline; \n } \n \n function arraysEqual(a, b) {\n if (a === b) return true;\n if (a === null || b === null) return false;\n if (a.length != b.length) return false;\n\n for (var i = 0; i < a.length; ++i) {\n if (a[i] !== b[i]) return false;\n }\n return true;\n }\n \n \n function updateRoute(route, data) {\n if (route.latIndex > -1 && route.lngIndex > -1) {\n var latData = data[route.latIndex].data;\n var lngData = data[route.lngIndex].data;\n if (latData.length > 0 && lngData.length > 0) {\n var locations = [];\n for (var i = 0; i < latData.length; i++) {\n var lat = latData[i][1];\n var lng = lngData[i][1];\n var location = new google.maps.LatLng(lat, lng);\n locations.push(location);\n }\n var markerLocation;\n if (locations.length > 0) {\n markerLocation = locations[locations.length-1];\n }\n if (!route.polyline) {\n route.polyline = createPolyline(locations, route.settings);\n if (markerLocation) {\n route.marker = createMarker(markerLocation, route.settings);\n }\n polylines.push(route.polyline);\n return true;\n } else {\n var prevPath = route.polyline.getPath();\n if (!prevPath || !arraysEqual(prevPath.getArray(), locations)) {\n route.polyline.setPath(locations);\n if (markerLocation) {\n if (!route.marker) {\n route.marker = createMarker(markerLocation, route.settings);\n } else {\n route.marker.setPosition(markerLocation);\n }\n }\n return true;\n }\n }\n }\n }\n return false;\n }\n \n function extendBounds(bounds, polyline) {\n if (polyline && polyline.getPath()) {\n var locations = polyline.getPath();\n for (var i = 0; i < locations.getLength(); i++) {\n bounds.extend(locations.getAt(i));\n }\n }\n }\n \n function loadRoutes(data) {\n var bounds = new google.maps.LatLngBounds();\n routes = [];\n var datasourceIndex = -1;\n var routeSettings;\n var datasource;\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n if (!datasource || datasource != datasourceData.datasource) {\n datasourceIndex++;\n datasource = datasourceData.datasource;\n routeSettings = routesSettings[datasourceIndex];\n }\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === routeSettings.latKeyName ||\n dataKey.label === routeSettings.lngKeyName) {\n var route = routes[datasourceIndex];\n if (!route) {\n route = {\n latIndex: -1,\n lngIndex: -1,\n settings: routeSettings\n };\n routes[datasourceIndex] = route;\n } else if (route.polyline) {\n continue;\n }\n if (dataKey.label === routeSettings.latKeyName) {\n route.latIndex = i;\n } else {\n route.lngIndex = i;\n }\n if (route.latIndex > -1 && route.lngIndex > -1) {\n updateRoute(route, data);\n if (route.polyline) {\n extendBounds(bounds, route.polyline);\n }\n }\n }\n }\n fitMapBounds(bounds);\n }\n \n \n function updateRoutes(data) {\n var routesChanged = false;\n var bounds = new google.maps.LatLngBounds();\n for (var r in routes) {\n var route = routes[r];\n routesChanged |= updateRoute(route, data);\n if (route.polyline) {\n extendBounds(bounds, route.polyline);\n }\n }\n if (!dontFitMapBounds && routesChanged) {\n fitMapBounds(bounds);\n }\n }\n \n function fitMapBounds(bounds) {\n google.maps.event.addListenerOnce(map, ''bounds_changed'', function(event) {\n var zoomLevel = defaultZoomLevel || map.getZoom();\n this.setZoom(zoomLevel);\n if (!defaultZoomLevel && this.getZoom() > 15) {\n this.setZoom(15);\n }\n });\n map.fitBounds(bounds);\n }\n\n if (map) {\n if (data) {\n if (!routes) {\n loadRoutes(data);\n } else {\n updateRoutes(data);\n }\n }\n if (sizeChanged) {\n google.maps.event.trigger(map, \"resize\");\n if (!dontFitMapBounds) {\n var bounds = new google.maps.LatLngBounds();\n for (var p in polylines) {\n extendBounds(bounds, polylines[p]);\n }\n fitMapBounds(bounds);\n }\n }\n \n for (var t in tooltips) {\n var tooltip = tooltips[t];\n var text = tooltip.pattern;\n var replaceInfo = tooltip.replaceInfo;\n for (var v in replaceInfo.variables) {\n var variableInfo = replaceInfo.variables[v];\n var txtVal = '''';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n } else {\n txtVal = val;\n }\n }\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n tooltip.infowindow.setContent(text);\n }\n \n }\n\n};","settingsSchema":"{\n \"schema\": {\n \"title\": \"Route Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"gmApiKey\": {\n \"title\": \"Google Maps API Key\",\n \"type\": \"string\"\n },\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all routes\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"routesSettings\": {\n \"title\": \"Routes settings, same order as datasources\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Route settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n },\n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#<key index>} units'' )\",\n \"type\": \"string\",\n \"default\": \"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"strokeWeight\": {\n \"title\": \"Stroke weight\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"strokeOpacity\": {\n \"title\": \"Stroke opacity\",\n \"type\": \"number\",\n \"default\": 1.0\n }\n }\n }\n }\n },\n \"required\": [\n \"gmApiKey\"\n ]\n },\n \"form\": [\n \"gmApiKey\",\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"routesSettings\",\n \"items\": [\n \"routesSettings[].latKeyName\",\n \"routesSettings[].lngKeyName\",\n \"routesSettings[].showLabel\",\n \"routesSettings[].label\",\n \"routesSettings[].tooltipPattern\",\n {\n \"key\": \"routesSettings[].color\",\n \"type\": \"color\"\n },\n \"routesSettings[].strokeWeight\",\n \"routesSettings[].strokeOpacity\"\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.3467277073670627,\"funcBody\":\"var lats = [37.7696499,\\n37.7699074,\\n37.7699536,\\n37.7697242,\\n37.7695189,\\n37.7696889,\\n37.7697153,\\n37.7701244,\\n37.7700604,\\n37.7705491,\\n37.7715705,\\n37.771752,\\n37.7707533,\\n37.769866];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lats[i];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.058309787276281666,\"funcBody\":\"var lons = [-122.4261215,\\n-122.4219157,\\n-122.4199623,\\n-122.4179074,\\n-122.4155876,\\n-122.4155521,\\n-122.4163203,\\n-122.4193876,\\n-122.4210496,\\n-122.422284,\\n-122.4232717,\\n-122.4235138,\\n-122.4247605,\\n-122.4258812];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lons[i];\"}],\"intervalSec\":60}],\"timewindow\":{\"realtime\":{\"timewindowMs\":30000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"fitMapBounds\":false,\"routesSettings\":[{\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"color\":\"#1976d2\",\"strokeWeight\":4,\"strokeOpacity\":0.65,\"label\":\"First route\",\"tooltipPattern\":\"<b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}\"}],\"defaultZoomLevel\":16},\"title\":\"Route Map\"}"}',
  137 +'{"type":"timeseries","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".error {\n color: red;\n}\n.tb-labels {\n color: #222;\n font: 12px/1.5 \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n text-align: center;\n width: 100px;\n white-space: nowrap;\n}","controllerScript":"var map;\n\nvar routesSettings = [];\nvar routes;\nvar polylines = [];\n\nvar defaultZoomLevel;\nvar dontFitMapBounds;\n\nvar varsRegex = /\\$\\{([^\\}]*)\\}/g;\n\nvar tooltips = [];\n\nfns.init = function(containerElement, settings, datasources,\n data) {\n \n if (settings.defaultZoomLevel) {\n if (settings.defaultZoomLevel > 0 && settings.defaultZoomLevel < 21) {\n defaultZoomLevel = Math.floor(settings.defaultZoomLevel);\n }\n }\n \n dontFitMapBounds = settings.fitMapBounds === false;\n \n function procesTooltipPattern(pattern, datasource, datasourceOffset) {\n var match = varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split('':'');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith(''#'')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = datasourceOffset + n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < datasource.dataKeys.length; i++) {\n var dataKey = datasource.dataKeys[i];\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = datasourceOffset + i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = varsRegex.exec(pattern);\n }\n return replaceInfo;\n }\n\n \n var configuredRoutesSettings = settings.routesSettings;\n if (!configuredRoutesSettings) {\n configuredRoutesSettings = [];\n }\n \n var datasourceOffset = 0;\n for (var i=0;i<datasources.length;i++) {\n routesSettings[i] = {\n latKeyName: \"lat\",\n lngKeyName: \"lng\",\n showLabel: true,\n label: datasources[i].name, \n color: \"#FE7569\",\n strokeWeight: 2,\n strokeOpacity: 1.0,\n tooltipPattern: \"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${lng:7}\"\n };\n if (configuredRoutesSettings[i]) {\n routesSettings[i].latKeyName = configuredRoutesSettings[i].latKeyName || routesSettings[i].latKeyName;\n routesSettings[i].lngKeyName = configuredRoutesSettings[i].lngKeyName || routesSettings[i].lngKeyName;\n routesSettings[i].tooltipPattern = configuredRoutesSettings[i].tooltipPattern || \"<b>Latitude:</b> ${\"+routesSettings[i].latKeyName+\":7}<br/><b>Longitude:</b> ${\"+routesSettings[i].lngKeyName+\":7}\";\n \n routesSettings[i].tooltipReplaceInfo = procesTooltipPattern(routesSettings[i].tooltipPattern, datasources[i], datasourceOffset);\n \n routesSettings[i].showLabel = configuredRoutesSettings[i].showLabel !== false;\n routesSettings[i].label = configuredRoutesSettings[i].label || routesSettings[i].label;\n routesSettings[i].color = configuredRoutesSettings[i].color ? tinycolor(configuredRoutesSettings[i].color).toHexString() : routesSettings[i].color;\n routesSettings[i].strokeWeight = configuredRoutesSettings[i].strokeWeight || routesSettings[i].strokeWeight;\n routesSettings[i].strokeOpacity = typeof configuredRoutesSettings[i].strokeOpacity !== \"undefined\" ? configuredRoutesSettings[i].strokeOpacity : routesSettings[i].strokeOpacity; \n }\n datasourceOffset += datasources[i].dataKeys.length;\n }\n\n var mapId = '''' + Math.random().toString(36).substr(2, 9);\n \n function clearGlobalId() {\n if ($window.loadingGmId && $window.loadingGmId === mapId) {\n $window.loadingGmId = null;\n }\n }\n \n $window.gm_authFailure = function() {\n if ($window.loadingGmId && $window.loadingGmId === mapId) {\n $window.loadingGmId = null;\n $window.gmApiKeys[apiKey].error = ''Unable to authentificate for Google Map API.</br>Please check your API key.'';\n displayError($window.gmApiKeys[apiKey].error);\n }\n };\n \n function displayError(message) {\n $(containerElement).html(\n \"<div class=''error''>\"+ message + \"</div>\"\n );\n }\n\n var initMapFunctionName = ''initGoogleMap_'' + mapId;\n $window[initMapFunctionName] = function() {\n lazyLoad.load({ type: ''js'', path: ''https://cdn.rawgit.com/googlemaps/v3-utility-library/master/markerwithlabel/src/markerwithlabel.js'' }).then(\n function success() {\n initMap();\n },\n function fail() {\n clearGloabalId();\n $window.gmApiKeys[apiKey].error = ''Google map api load failed!</br>''+e;\n displayError($window.gmApiKeys[apiKey].error);\n }\n );\n \n }; \n \n var apiKey = settings.gmApiKey || '''';\n\n if (apiKey && apiKey.length > 0) {\n if (!$window.gmApiKeys) {\n $window.gmApiKeys = {};\n }\n if ($window.gmApiKeys[apiKey]) {\n if ($window.gmApiKeys[apiKey].error) {\n displayError($window.gmApiKeys[apiKey].error);\n } else {\n initMap();\n }\n } else {\n $window.gmApiKeys[apiKey] = {};\n var googleMapScriptRes = ''https://maps.googleapis.com/maps/api/js?key=''+apiKey+''&callback=''+initMapFunctionName;\n \n $window.loadingGmId = mapId;\n lazyLoad.load({ type: ''js'', path: googleMapScriptRes }).then(\n function success() {\n setTimeout(clearGlobalId, 2000);\n },\n function fail(e) {\n clearGloabalId();\n $window.gmApiKeys[apiKey].error = ''Google map api load failed!</br>''+e;\n displayError($window.gmApiKeys[apiKey].error);\n }\n );\n }\n } else {\n displayError(''No Google Map Api Key provided!'');\n }\n\n function initMap() {\n \n map = new google.maps.Map(containerElement, {\n scrollwheel: false,\n zoom: defaultZoomLevel || 8\n });\n\n }\n\n}\n\n\nfns.redraw = function(containerElement, width, height, data,\n timeWindow, sizeChanged) {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n \n function padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n \n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n \n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split(''.'');\n s = int - strVal[0].length;\n \n for (; i < s; ++i) {\n strVal[0] = ''0'' + strVal[0];\n }\n \n strVal = (n ? ''-'' : '''') + strVal[0] + ''.'' + strVal[1];\n }\n \n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n \n for (; i < s; ++i) {\n strVal = ''0'' + strVal;\n }\n \n strVal = (n ? ''-'' : '''') + strVal;\n }\n \n return strVal;\n } \n \n function createMarker(location, settings) {\n var pinColor = settings.color.substr(1);\n var pinImage = new google.maps.MarkerImage(\"http://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=%E2%80%A2|\" + pinColor,\n new google.maps.Size(21, 34),\n new google.maps.Point(0,0),\n new google.maps.Point(10, 34));\n var pinShadow = new google.maps.MarkerImage(\"http://chart.apis.google.com/chart?chst=d_map_pin_shadow\",\n new google.maps.Size(40, 37),\n new google.maps.Point(0, 0),\n new google.maps.Point(12, 35)); \n var marker;\n if (settings.showLabel) { \n marker = new MarkerWithLabel({\n position: location, \n map: map,\n icon: pinImage,\n shadow: pinShadow,\n labelContent: ''<b>''+settings.label+''</b>'',\n labelClass: \"tb-labels\",\n labelAnchor: new google.maps.Point(50, 55)\n }); \n } else {\n marker = new google.maps.Marker({\n position: location, \n map: map,\n icon: pinImage,\n shadow: pinShadow\n }); \n }\n \n createTooltip(marker, settings.tooltipPattern, settings.tooltipReplaceInfo);\n \n return marker; \n }\n \n function createTooltip(marker, pattern, replaceInfo) {\n var infowindow = new google.maps.InfoWindow({\n content: ''''\n });\n marker.addListener(''click'', function() {\n infowindow.open(map, marker);\n });\n tooltips.push( {\n infowindow: infowindow,\n pattern: pattern,\n replaceInfo: replaceInfo\n });\n }\n\n function createPolyline(locations, settings) {\n var polyline = new google.maps.Polyline({\n path: locations,\n strokeColor: settings.color,\n strokeOpacity: settings.strokeOpacity,\n strokeWeight: settings.strokeWeight,\n map: map\n });\n \n return polyline; \n } \n \n function arraysEqual(a, b) {\n if (a === b) return true;\n if (a === null || b === null) return false;\n if (a.length != b.length) return false;\n\n for (var i = 0; i < a.length; ++i) {\n if (!a[i].equals(b[i])) return false;\n }\n return true;\n }\n \n \n function updateRoute(route, data) {\n if (route.latIndex > -1 && route.lngIndex > -1) {\n var latData = data[route.latIndex].data;\n var lngData = data[route.lngIndex].data;\n if (latData.length > 0 && lngData.length > 0) {\n var locations = [];\n for (var i = 0; i < latData.length; i++) {\n var lat = latData[i][1];\n var lng = lngData[i][1];\n var location = new google.maps.LatLng(lat, lng);\n if (i == 0 || !locations[locations.length-1].equals(location)) {\n locations.push(location);\n }\n }\n var markerLocation;\n if (locations.length > 0) {\n markerLocation = locations[locations.length-1];\n }\n if (!route.polyline) {\n route.polyline = createPolyline(locations, route.settings);\n if (markerLocation) {\n route.marker = createMarker(markerLocation, route.settings);\n }\n polylines.push(route.polyline);\n return true;\n } else {\n var prevPath = route.polyline.getPath();\n if (!prevPath || !arraysEqual(prevPath.getArray(), locations)) {\n route.polyline.setPath(locations);\n if (markerLocation) {\n if (!route.marker) {\n route.marker = createMarker(markerLocation, route.settings);\n } else {\n route.marker.setPosition(markerLocation);\n }\n }\n return true;\n }\n }\n }\n }\n return false;\n }\n \n function extendBounds(bounds, polyline) {\n if (polyline && polyline.getPath()) {\n var locations = polyline.getPath();\n for (var i = 0; i < locations.getLength(); i++) {\n bounds.extend(locations.getAt(i));\n }\n }\n }\n \n function loadRoutes(data) {\n var bounds = new google.maps.LatLngBounds();\n routes = [];\n var datasourceIndex = -1;\n var routeSettings;\n var datasource;\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n if (!datasource || datasource != datasourceData.datasource) {\n datasourceIndex++;\n datasource = datasourceData.datasource;\n routeSettings = routesSettings[datasourceIndex];\n }\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === routeSettings.latKeyName ||\n dataKey.label === routeSettings.lngKeyName) {\n var route = routes[datasourceIndex];\n if (!route) {\n route = {\n latIndex: -1,\n lngIndex: -1,\n settings: routeSettings\n };\n routes[datasourceIndex] = route;\n } else if (route.polyline) {\n continue;\n }\n if (dataKey.label === routeSettings.latKeyName) {\n route.latIndex = i;\n } else {\n route.lngIndex = i;\n }\n if (route.latIndex > -1 && route.lngIndex > -1) {\n updateRoute(route, data);\n if (route.polyline) {\n extendBounds(bounds, route.polyline);\n }\n }\n }\n }\n fitMapBounds(bounds);\n }\n \n \n function updateRoutes(data) {\n var routesChanged = false;\n var bounds = new google.maps.LatLngBounds();\n for (var r in routes) {\n var route = routes[r];\n routesChanged |= updateRoute(route, data);\n if (route.polyline) {\n extendBounds(bounds, route.polyline);\n }\n }\n if (!dontFitMapBounds && routesChanged) {\n fitMapBounds(bounds);\n }\n }\n \n function fitMapBounds(bounds) {\n google.maps.event.addListenerOnce(map, ''bounds_changed'', function(event) {\n var newZoomLevel = map.getZoom();\n if (dontFitMapBounds && defaultZoomLevel) {\n newZoomLevel = defaultZoomLevel;\n }\n map.setZoom(newZoomLevel);\n if (!defaultZoomLevel && map.getZoom() > 18) {\n map.setZoom(18);\n }\n });\n map.fitBounds(bounds);\n }\n\n if (map) {\n if (data) {\n if (!routes) {\n loadRoutes(data);\n } else {\n updateRoutes(data);\n }\n }\n if (sizeChanged) {\n google.maps.event.trigger(map, \"resize\");\n if (!dontFitMapBounds) {\n var bounds = new google.maps.LatLngBounds();\n for (var p in polylines) {\n extendBounds(bounds, polylines[p]);\n }\n fitMapBounds(bounds);\n }\n }\n \n for (var t in tooltips) {\n var tooltip = tooltips[t];\n var text = tooltip.pattern;\n var replaceInfo = tooltip.replaceInfo;\n for (var v in replaceInfo.variables) {\n var variableInfo = replaceInfo.variables[v];\n var txtVal = '''';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n } else {\n txtVal = val;\n }\n }\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n tooltip.infowindow.setContent(text);\n }\n \n }\n\n};","settingsSchema":"{\n \"schema\": {\n \"title\": \"Route Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"gmApiKey\": {\n \"title\": \"Google Maps API Key\",\n \"type\": \"string\"\n },\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all routes\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"routesSettings\": {\n \"title\": \"Routes settings, same order as datasources\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Route settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n },\n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#<key index>} units'' )\",\n \"type\": \"string\",\n \"default\": \"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"strokeWeight\": {\n \"title\": \"Stroke weight\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"strokeOpacity\": {\n \"title\": \"Stroke opacity\",\n \"type\": \"number\",\n \"default\": 1.0\n }\n }\n }\n }\n },\n \"required\": [\n \"gmApiKey\"\n ]\n },\n \"form\": [\n \"gmApiKey\",\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"routesSettings\",\n \"items\": [\n \"routesSettings[].latKeyName\",\n \"routesSettings[].lngKeyName\",\n \"routesSettings[].showLabel\",\n \"routesSettings[].label\",\n \"routesSettings[].tooltipPattern\",\n {\n \"key\": \"routesSettings[].color\",\n \"type\": \"color\"\n },\n \"routesSettings[].strokeWeight\",\n \"routesSettings[].strokeOpacity\"\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.3467277073670627,\"funcBody\":\"var lats = [37.7696499,\\n37.7699074,\\n37.7699536,\\n37.7697242,\\n37.7695189,\\n37.7696889,\\n37.7697153,\\n37.7701244,\\n37.7700604,\\n37.7705491,\\n37.7715705,\\n37.771752,\\n37.7707533,\\n37.769866];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lats[i];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.058309787276281666,\"funcBody\":\"var lons = [-122.4261215,\\n-122.4219157,\\n-122.4199623,\\n-122.4179074,\\n-122.4155876,\\n-122.4155521,\\n-122.4163203,\\n-122.4193876,\\n-122.4210496,\\n-122.422284,\\n-122.4232717,\\n-122.4235138,\\n-122.4247605,\\n-122.4258812];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lons[i];\"}],\"intervalSec\":60}],\"timewindow\":{\"realtime\":{\"timewindowMs\":30000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"fitMapBounds\":true,\"routesSettings\":[{\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"color\":\"#1976d2\",\"strokeWeight\":4,\"strokeOpacity\":0.65,\"label\":\"First route\",\"tooltipPattern\":\"<b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}\"}]},\"title\":\"Route Map\"}"}',
138 138 'Route Map' );
139 139
140 140 INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" )
... ... @@ -208,6 +208,11 @@ VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'openstreetmap',
208 208 'OpenStreetMap' );
209 209
210 210 INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" )
  211 +VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'route_map_openstreetmap',
  212 +'{"type":"timeseries","sizeX":8.5,"sizeY":6,"resources":[{"url":"https://unpkg.com/leaflet@1.0.1/dist/leaflet.css"},{"url":"https://unpkg.com/leaflet@1.0.1/dist/leaflet.js"}],"templateHtml":"","templateCss":".leaflet-zoom-box {\n\tz-index: 9;\n}\n\n.leaflet-pane { z-index: 4; }\n\n.leaflet-tile-pane { z-index: 2; }\n.leaflet-overlay-pane { z-index: 4; }\n.leaflet-shadow-pane { z-index: 5; }\n.leaflet-marker-pane { z-index: 6; }\n.leaflet-tooltip-pane { z-index: 7; }\n.leaflet-popup-pane { z-index: 8; }\n\n.leaflet-map-pane canvas { z-index: 1; }\n.leaflet-map-pane svg { z-index: 2; }\n\n.leaflet-control {\n\tz-index: 9;\n}\n.leaflet-top,\n.leaflet-bottom {\n\tz-index: 11;\n}\n\n.tb-marker-label {\n border: none;\n background: none;\n box-shadow: none;\n}\n\n.tb-marker-label:before {\n border: none;\n background: none;\n}\n","controllerScript":"var map;\n\nvar routesSettings = [];\nvar routes;\nvar polylines = [];\n\nvar defaultZoomLevel;\nvar dontFitMapBounds;\n\nvar varsRegex = /\\$\\{([^\\}]*)\\}/g;\n\nvar tooltips = [];\n\nfns.init = function(containerElement, settings, datasources,\n data) {\n \n if (settings.defaultZoomLevel) {\n if (settings.defaultZoomLevel > 0 && settings.defaultZoomLevel < 21) {\n defaultZoomLevel = Math.floor(settings.defaultZoomLevel);\n }\n }\n \n dontFitMapBounds = settings.fitMapBounds === false;\n \n function procesTooltipPattern(pattern, datasource, datasourceOffset) {\n var match = varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split('':'');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith(''#'')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = datasourceOffset + n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < datasource.dataKeys.length; i++) {\n var dataKey = datasource.dataKeys[i];\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = datasourceOffset + i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = varsRegex.exec(pattern);\n }\n return replaceInfo;\n } \n \n var configuredRoutesSettings = settings.routesSettings;\n if (!configuredRoutesSettings) {\n configuredRoutesSettings = [];\n }\n \n var datasourceOffset = 0;\n for (var i=0;i<datasources.length;i++) {\n routesSettings[i] = {\n latKeyName: \"lat\",\n lngKeyName: \"lng\",\n showLabel: true,\n label: datasources[i].name, \n color: \"#FE7569\",\n strokeWeight: 2,\n strokeOpacity: 1.0,\n tooltipPattern: \"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${lng:7}\"\n };\n if (configuredRoutesSettings[i]) {\n \n routesSettings[i].latKeyName = configuredRoutesSettings[i].latKeyName || routesSettings[i].latKeyName;\n routesSettings[i].lngKeyName = configuredRoutesSettings[i].lngKeyName || routesSettings[i].lngKeyName;\n routesSettings[i].tooltipPattern = configuredRoutesSettings[i].tooltipPattern || \"<b>Latitude:</b> ${\"+routesSettings[i].latKeyName+\":7}<br/><b>Longitude:</b> ${\"+routesSettings[i].lngKeyName+\":7}\";\n \n routesSettings[i].tooltipReplaceInfo = procesTooltipPattern(routesSettings[i].tooltipPattern, datasources[i], datasourceOffset);\n \n routesSettings[i].showLabel = configuredRoutesSettings[i].showLabel !== false;\n routesSettings[i].label = configuredRoutesSettings[i].label || routesSettings[i].label;\n routesSettings[i].color = configuredRoutesSettings[i].color ? tinycolor(configuredRoutesSettings[i].color).toHex() : routesSettings[i].color;\n routesSettings[i].strokeWeight = configuredRoutesSettings[i].strokeWeight || routesSettings[i].strokeWeight;\n routesSettings[i].strokeOpacity = typeof configuredRoutesSettings[i].strokeOpacity !== \"undefined\" ? configuredRoutesSettings[i].strokeOpacity : routesSettings[i].strokeOpacity; \n }\n datasourceOffset += datasources[i].dataKeys.length;\n }\n \n map = L.map(containerElement).setView([0, 0], defaultZoomLevel || 8);\n\n L.tileLayer(''http://{s}.tile.osm.org/{z}/{x}/{y}.png'', {\n attribution: ''&copy; <a href=\"http://osm.org/copyright\">OpenStreetMap</a> contributors''\n }).addTo(map);\n\n\n}\n\n\nfns.redraw = function(containerElement, width, height, data,\n timeWindow, sizeChanged) {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n \n function padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n \n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n \n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split(''.'');\n s = int - strVal[0].length;\n \n for (; i < s; ++i) {\n strVal[0] = ''0'' + strVal[0];\n }\n \n strVal = (n ? ''-'' : '''') + strVal[0] + ''.'' + strVal[1];\n }\n \n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n \n for (; i < s; ++i) {\n strVal = ''0'' + strVal;\n }\n \n strVal = (n ? ''-'' : '''') + strVal;\n }\n \n return strVal;\n } \n \n function createMarker(location, settings) {\n var pinColor = settings.color;\n\n var icon = L.icon({\n iconUrl: ''http://chart.apis.google.com/chart?chst=d_map_pin_letter&chld=%E2%80%A2|'' + pinColor,\n iconSize: [21, 34],\n iconAnchor: [10, 34],\n popupAnchor: [0, -34],\n shadowUrl: ''http://chart.apis.google.com/chart?chst=d_map_pin_shadow'',\n shadowSize: [40, 37],\n shadowAnchor: [12, 35]\n });\n \n var marker = L.marker(location, {icon: icon}).addTo(map);\n if (settings.showLabel) {\n marker.bindTooltip(''<b>'' + settings.label + ''</b>'', { className: ''tb-marker-label'', permanent: true, direction: ''top'', offset: [0, -24] });\n }\n \n createTooltip(marker, settings.tooltipPattern, settings.tooltipReplaceInfo);\n \n return marker;\n }\n \n \n function createTooltip(marker, pattern, replaceInfo) {\n var popup = L.popup();\n popup.setContent('''');\n marker.bindPopup(popup, {autoClose: false, closeOnClick: false});\n tooltips.push( {\n popup: popup,\n pattern: pattern,\n replaceInfo: replaceInfo\n });\n }\n \n function createPolyline(locations, settings) {\n var polyline = L.polyline(locations, \n {\n color: \"#\" + settings.color,\n opacity: settings.strokeOpacity,\n weight: settings.strokeWeight\n }\n ).addTo(map);\n return polyline; \n } \n \n function arraysEqual(a, b) {\n if (a === b) return true;\n if (a === null || b === null) return false;\n if (a.length != b.length) return false;\n\n for (var i = 0; i < a.length; ++i) {\n if (!a[i].equals(b[i])) return false;\n }\n return true;\n }\n \n function updateRoute(route, data) {\n if (route.latIndex > -1 && route.lngIndex > -1) {\n var latData = data[route.latIndex].data;\n var lngData = data[route.lngIndex].data;\n if (latData.length > 0 && lngData.length > 0) {\n var locations = [];\n for (var i = 0; i < latData.length; i++) {\n var lat = latData[i][1];\n var lng = lngData[i][1];\n var location = L.latLng(lat, lng);\n if (i == 0 || !locations[locations.length-1].equals(location)) {\n locations.push(location);\n }\n }\n var markerLocation;\n if (locations.length > 0) {\n markerLocation = locations[locations.length-1];\n }\n if (!route.polyline) {\n route.polyline = createPolyline(locations, route.settings);\n if (markerLocation) {\n route.marker = createMarker(markerLocation, route.settings);\n }\n polylines.push(route.polyline);\n return true;\n } else {\n var prevPath = route.polyline.getLatLngs();\n if (!prevPath || !arraysEqual(prevPath, locations)) {\n route.polyline.setLatLngs(locations);\n if (markerLocation) {\n if (!route.marker) {\n route.marker = createMarker(markerLocation, route.settings);\n } else {\n route.marker.setLatLng(markerLocation);\n }\n }\n return true;\n }\n }\n }\n }\n return false;\n } \n \n function extendBounds(bounds, polyline) {\n if (polyline && polyline.getLatLngs()) {\n bounds.extend(polyline.getBounds());\n }\n }\n \n function loadRoutes(data) {\n var bounds = L.latLngBounds();\n routes = [];\n var datasourceIndex = -1;\n var routeSettings;\n var datasource;\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n if (!datasource || datasource != datasourceData.datasource) {\n datasourceIndex++;\n datasource = datasourceData.datasource;\n routeSettings = routesSettings[datasourceIndex];\n }\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === routeSettings.latKeyName ||\n dataKey.label === routeSettings.lngKeyName) {\n var route = routes[datasourceIndex];\n if (!route) {\n route = {\n latIndex: -1,\n lngIndex: -1,\n settings: routeSettings\n };\n routes[datasourceIndex] = route;\n } else if (route.polyline) {\n continue;\n }\n if (dataKey.label === routeSettings.latKeyName) {\n route.latIndex = i;\n } else {\n route.lngIndex = i;\n }\n if (route.latIndex > -1 && route.lngIndex > -1) {\n updateRoute(route, data);\n if (route.polyline) {\n extendBounds(bounds, route.polyline);\n }\n }\n }\n }\n fitMapBounds(bounds);\n }\n \n function updateRoutes(data) {\n var routesChanged = false;\n var bounds = L.latLngBounds();\n for (var r in routes) {\n var route = routes[r];\n routesChanged |= updateRoute(route, data);\n if (route.polyline) {\n extendBounds(bounds, route.polyline);\n }\n }\n if (!dontFitMapBounds && routesChanged) {\n fitMapBounds(bounds);\n }\n }\n \n function fitMapBounds(bounds) {\n map.once(''zoomend'', function(event) {\n var newZoomLevel = map.getZoom();\n if (dontFitMapBounds && defaultZoomLevel) {\n newZoomLevel = defaultZoomLevel;\n }\n map.setZoom(newZoomLevel, {animate: false});\n if (!defaultZoomLevel && this.getZoom() > 18) {\n map.setZoom(18, {animate: false});\n }\n });\n map.fitBounds(bounds, {padding: [50, 50], animate: false});\n }\n\n if (map) {\n if (data) {\n if (!routes) {\n loadRoutes(data);\n } else {\n updateRoutes(data);\n }\n }\n if (sizeChanged) {\n map.invalidateSize(true);\n if (!dontFitMapBounds) {\n var bounds = L.latLngBounds();\n for (var p in polylines) {\n extendBounds(bounds, polylines[p]);\n }\n fitMapBounds(bounds);\n } \n }\n \n for (var t in tooltips) {\n var tooltip = tooltips[t];\n var text = tooltip.pattern;\n var replaceInfo = tooltip.replaceInfo;\n if (replaceInfo && replaceInfo.variables) {\n for (var v in replaceInfo.variables) {\n var variableInfo = replaceInfo.variables[v];\n var txtVal = '''';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n } else {\n txtVal = val;\n }\n }\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n }\n tooltip.popup.setContent(text);\n } \n \n }\n\n};","settingsSchema":"{\n \"schema\": {\n \"title\": \"Route Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all markers\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"routesSettings\": {\n \"title\": \"Routes settings, same order as datasources\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Route settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n },\n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#<key index>} units'' )\",\n \"type\": \"string\",\n \"default\": \"<b>Latitude:</b> ${lat:7}<br/><b>Longitude:</b> ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"strokeWeight\": {\n \"title\": \"Stroke weight\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"strokeOpacity\": {\n \"title\": \"Stroke opacity\",\n \"type\": \"number\",\n \"default\": 1.0\n }\n }\n }\n }\n },\n \"required\": [\n ]\n },\n \"form\": [\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"routesSettings\",\n \"items\": [\n \"routesSettings[].latKeyName\",\n \"routesSettings[].lngKeyName\",\n \"routesSettings[].showLabel\",\n \"routesSettings[].label\",\n \"routesSettings[].tooltipPattern\",\n {\n \"key\": \"routesSettings[].color\",\n \"type\": \"color\"\n },\n \"routesSettings[].strokeWeight\",\n \"routesSettings[].strokeOpacity\"\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8950926999078694,\"funcBody\":\"var lats = [37.7696499,\\n37.7699074,\\n37.7699536,\\n37.7697242,\\n37.7695189,\\n37.7696889,\\n37.7697153,\\n37.7701244,\\n37.7700604,\\n37.7705491,\\n37.7715705,\\n37.771752,\\n37.7707533,\\n37.769866];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lats[i];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.2757675428823283,\"funcBody\":\"var lons = [-122.4261215,\\n-122.4219157,\\n-122.4199623,\\n-122.4179074,\\n-122.4155876,\\n-122.4155521,\\n-122.4163203,\\n-122.4193876,\\n-122.4210496,\\n-122.422284,\\n-122.4232717,\\n-122.4235138,\\n-122.4247605,\\n-122.4258812];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lons[i];\"}],\"intervalSec\":60}],\"timewindow\":{\"realtime\":{\"timewindowMs\":30000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"fitMapBounds\":true,\"routesSettings\":[{\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"tooltipPattern\":\"<b>Latitude:</b> ${latitude:7}<br/><b>Longitude:</b> ${longitude:7}\",\"strokeWeight\":4,\"label\":\"First route\",\"color\":\"#3d5afe\",\"strokeOpacity\":1}]},\"title\":\"Route Map - OpenStreetMap\"}"}',
  213 +'Route Map - OpenStreetMap' );
  214 +
  215 +INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" )
211 216 VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', 'temperature_radial_gauge_canvas_gauges',
212 217 '{"type":"latest","sizeX":6,"sizeY":5,"resources":[],"templateHtml":"<canvas id=\"radialGauge\"></canvas>\n","templateCss":"","controllerScript":"var gauge;\n\nfns.init = function(containerElement, settings, datasources,\n data) {\n gauge = new TbAnalogueRadialGauge(containerElement, settings, data, ''radialGauge''); \n\n}\n\n\nfns.redraw = function(containerElement, width, height, data, timeWindow, sizeChanged) {\n gauge.redraw(width, height, data, sizeChanged);\n};\n\nfns.destroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"minValue\": {\n \"title\": \"Minimum value\",\n \"type\": \"number\",\n \"default\": 0\n },\n \"maxValue\": {\n \"title\": \"Maximum value\",\n \"type\": \"number\",\n \"default\": 100\n },\n \"unitTitle\": {\n \"title\": \"Unit title\",\n \"type\": \"string\",\n \"default\": null\n },\n \"showUnitTitle\": {\n \"title\": \"Show unit title\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"units\": {\n \"title\": \"Units\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"majorTicksCount\": {\n \"title\": \"Major ticks count\",\n \"type\": \"number\",\n \"default\": null\n },\n \"minorTicks\": {\n \"title\": \"Minor ticks count\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"valueBox\": {\n \"title\": \"Show value box\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"valueInt\": {\n \"title\": \"Digits count for integer part of value\",\n \"type\": \"number\",\n \"default\": 3\n },\n \"valueDec\": {\n \"title\": \"Digits count for decimal part of value\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"defaultColor\": {\n \"title\": \"Default color\",\n \"type\": \"string\",\n \"default\": null\n },\n \"colorPlate\": {\n \"title\": \"Plate color\",\n \"type\": \"string\",\n \"default\": \"#fff\"\n },\n \"colorMajorTicks\": {\n \"title\": \"Major ticks color\",\n \"type\": \"string\",\n \"default\": \"#444\"\n },\n \"colorMinorTicks\": {\n \"title\": \"Minor ticks color\",\n \"type\": \"string\",\n \"default\": \"#666\"\n },\n \"colorNeedle\": {\n \"title\": \"Needle color\",\n \"type\": \"string\",\n \"default\": null\n },\n \"colorNeedleEnd\": {\n \"title\": \"Needle color - end gradient\",\n \"type\": \"string\",\n \"default\": null\n },\n \"colorNeedleShadowUp\": {\n \"title\": \"Upper half of the needle shadow color\",\n \"type\": \"string\",\n \"default\": \"rgba(2,255,255,0.2)\"\n },\n \"colorNeedleShadowDown\": {\n \"title\": \"Drop shadow needle color.\",\n \"type\": \"string\",\n \"default\": \"rgba(188,143,143,0.45)\"\n },\n \"colorValueBoxRect\": {\n \"title\": \"Value box rectangle stroke color\",\n \"type\": \"string\",\n \"default\": \"#888\"\n },\n \"colorValueBoxRectEnd\": {\n \"title\": \"Value box rectangle stroke color - end gradient\",\n \"type\": \"string\",\n \"default\": \"#666\"\n },\n \"colorValueBoxBackground\": {\n \"title\": \"Value box background color\",\n \"type\": \"string\",\n \"default\": \"#babab2\"\n },\n \"colorValueBoxShadow\": {\n \"title\": \"Value box shadow color\",\n \"type\": \"string\",\n \"default\": \"rgba(0,0,0,1)\"\n },\n \"highlights\": {\n \"title\": \"Highlights\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Highlight\",\n \"type\": \"object\",\n \"properties\": {\n \"from\": {\n \"title\": \"From\",\n \"type\": \"number\"\n },\n \"to\": {\n \"title\": \"To\",\n \"type\": \"number\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n }\n }\n }\n },\n \"highlightsWidth\": {\n \"title\": \"Highlights width\",\n \"type\": \"number\",\n \"default\": 15\n },\n \"showBorder\": {\n \"title\": \"Show border\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"numbersFont\": {\n \"title\": \"Tick numbers font\",\n \"type\": \"object\",\n \"properties\": {\n \"family\": {\n \"title\": \"Font family\",\n \"type\": \"string\",\n \"default\": \"RobotoDraft\"\n },\n \"size\": {\n \"title\": \"Size\",\n \"type\": \"number\",\n \"default\": 18\n },\n \"style\": {\n \"title\": \"Style\",\n \"type\": \"string\",\n \"default\": \"normal\"\n },\n \"weight\": {\n \"title\": \"Weight\",\n \"type\": \"string\",\n \"default\": \"500\"\n },\n \"color\": {\n \"title\": \"color\",\n \"type\": \"string\",\n \"default\": null\n }\n }\n },\n \"titleFont\": {\n \"title\": \"Title text font\",\n \"type\": \"object\",\n \"properties\": {\n \"family\": {\n \"title\": \"Font family\",\n \"type\": \"string\",\n \"default\": \"RobotoDraft\"\n },\n \"size\": {\n \"title\": \"Size\",\n \"type\": \"number\",\n \"default\": 24\n },\n \"style\": {\n \"title\": \"Style\",\n \"type\": \"string\",\n \"default\": \"normal\"\n },\n \"weight\": {\n \"title\": \"Weight\",\n \"type\": \"string\",\n \"default\": \"500\"\n },\n \"color\": {\n \"title\": \"color\",\n \"type\": \"string\",\n \"default\": \"#888\"\n }\n }\n },\n \"unitsFont\": {\n \"title\": \"Units text font\",\n \"type\": \"object\",\n \"properties\": {\n \"family\": {\n \"title\": \"Font family\",\n \"type\": \"string\",\n \"default\": \"RobotoDraft\"\n },\n \"size\": {\n \"title\": \"Size\",\n \"type\": \"number\",\n \"default\": 22\n },\n \"style\": {\n \"title\": \"Style\",\n \"type\": \"string\",\n \"default\": \"normal\"\n },\n \"weight\": {\n \"title\": \"Weight\",\n \"type\": \"string\",\n \"default\": \"500\"\n },\n \"color\": {\n \"title\": \"color\",\n \"type\": \"string\",\n \"default\": \"#888\"\n }\n }\n },\n \"valueFont\": {\n \"title\": \"Value text font\",\n \"type\": \"object\",\n \"properties\": {\n \"family\": {\n \"title\": \"Font family\",\n \"type\": \"string\",\n \"default\": \"RobotoDraft\"\n },\n \"size\": {\n \"title\": \"Size\",\n \"type\": \"number\",\n \"default\": 40\n },\n \"style\": {\n \"title\": \"Style\",\n \"type\": \"string\",\n \"default\": \"normal\"\n },\n \"weight\": {\n \"title\": \"Weight\",\n \"type\": \"string\",\n \"default\": \"500\"\n },\n \"color\": {\n \"title\": \"color\",\n \"type\": \"string\",\n \"default\": \"#444\"\n },\n \"shadowColor\": {\n \"title\": \"Shadow color\",\n \"type\": \"string\",\n \"default\": \"rgba(0,0,0,0.3)\"\n }\n }\n },\n \"animation\": {\n \"title\": \"Enable animation\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"animationDuration\": {\n \"title\": \"Animation duration\",\n \"type\": \"number\",\n \"default\": 500\n },\n \"animationRule\": {\n \"title\": \"Animation rule\",\n \"type\": \"string\",\n \"default\": \"cycle\"\n },\n \"startAngle\": {\n \"title\": \"Start ticks angle\",\n \"type\": \"number\",\n \"default\": 45\n },\n \"ticksAngle\": {\n \"title\": \"Ticks angle\",\n \"type\": \"number\",\n \"default\": 270\n },\n \"needleCircleSize\": {\n \"title\": \"Needle circle size\",\n \"type\": \"number\",\n \"default\": 10\n }\n },\n \"required\": []\n },\n \"form\": [\n \"startAngle\",\n \"ticksAngle\",\n \"needleCircleSize\",\n \"minValue\",\n \"maxValue\",\n \"unitTitle\",\n \"showUnitTitle\",\n \"units\",\n \"majorTicksCount\",\n \"minorTicks\",\n \"valueBox\",\n \"valueInt\",\n \"valueDec\",\n {\n \"key\": \"defaultColor\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorPlate\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorMajorTicks\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorMinorTicks\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorNeedle\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorNeedleEnd\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorNeedleShadowUp\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorNeedleShadowDown\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorValueBoxRect\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorValueBoxRectEnd\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorValueBoxBackground\",\n \"type\": \"color\"\n },\n {\n \"key\": \"colorValueBoxShadow\",\n \"type\": \"color\"\n },\n {\n \"key\": \"highlights\",\n \"items\": [\n \"highlights[].from\",\n \"highlights[].to\",\n {\n \"key\": \"highlights[].color\",\n \"type\": \"color\"\n }\n ]\n },\n \"highlightsWidth\",\n \"showBorder\",\n {\n \"key\": \"numbersFont\",\n \"items\": [\n \"numbersFont.family\",\n \"numbersFont.size\",\n {\n \"key\": \"numbersFont.style\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"italic\",\n \"label\": \"Italic\"\n },\n {\n \"value\": \"oblique\",\n \"label\": \"Oblique\"\n }\n ]\n },\n {\n \"key\": \"numbersFont.weight\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"bold\",\n \"label\": \"Bold\"\n },\n {\n \"value\": \"bolder\",\n \"label\": \"Bolder\"\n },\n {\n \"value\": \"lighter\",\n \"label\": \"Lighter\"\n },\n {\n \"value\": \"100\",\n \"label\": \"100\"\n },\n {\n \"value\": \"200\",\n \"label\": \"200\"\n },\n {\n \"value\": \"300\",\n \"label\": \"300\"\n },\n {\n \"value\": \"400\",\n \"label\": \"400\"\n },\n {\n \"value\": \"500\",\n \"label\": \"500\"\n },\n {\n \"value\": \"600\",\n \"label\": \"600\"\n },\n {\n \"value\": \"700\",\n \"label\": \"800\"\n },\n {\n \"value\": \"800\",\n \"label\": \"800\"\n },\n {\n \"value\": \"900\",\n \"label\": \"900\"\n }\n ]\n },\n {\n \"key\": \"numbersFont.color\",\n \"type\": \"color\"\n }\n ]\n },\n {\n \"key\": \"titleFont\",\n \"items\": [\n \"titleFont.family\",\n \"titleFont.size\",\n {\n \"key\": \"titleFont.style\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"italic\",\n \"label\": \"Italic\"\n },\n {\n \"value\": \"oblique\",\n \"label\": \"Oblique\"\n }\n ]\n },\n {\n \"key\": \"titleFont.weight\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"bold\",\n \"label\": \"Bold\"\n },\n {\n \"value\": \"bolder\",\n \"label\": \"Bolder\"\n },\n {\n \"value\": \"lighter\",\n \"label\": \"Lighter\"\n },\n {\n \"value\": \"100\",\n \"label\": \"100\"\n },\n {\n \"value\": \"200\",\n \"label\": \"200\"\n },\n {\n \"value\": \"300\",\n \"label\": \"300\"\n },\n {\n \"value\": \"400\",\n \"label\": \"400\"\n },\n {\n \"value\": \"500\",\n \"label\": \"500\"\n },\n {\n \"value\": \"600\",\n \"label\": \"600\"\n },\n {\n \"value\": \"700\",\n \"label\": \"800\"\n },\n {\n \"value\": \"800\",\n \"label\": \"800\"\n },\n {\n \"value\": \"900\",\n \"label\": \"900\"\n }\n ]\n },\n {\n \"key\": \"titleFont.color\",\n \"type\": \"color\"\n }\n ]\n },\n {\n \"key\": \"unitsFont\",\n \"items\": [\n \"unitsFont.family\",\n \"unitsFont.size\",\n {\n \"key\": \"unitsFont.style\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"italic\",\n \"label\": \"Italic\"\n },\n {\n \"value\": \"oblique\",\n \"label\": \"Oblique\"\n }\n ]\n },\n {\n \"key\": \"unitsFont.weight\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"bold\",\n \"label\": \"Bold\"\n },\n {\n \"value\": \"bolder\",\n \"label\": \"Bolder\"\n },\n {\n \"value\": \"lighter\",\n \"label\": \"Lighter\"\n },\n {\n \"value\": \"100\",\n \"label\": \"100\"\n },\n {\n \"value\": \"200\",\n \"label\": \"200\"\n },\n {\n \"value\": \"300\",\n \"label\": \"300\"\n },\n {\n \"value\": \"400\",\n \"label\": \"400\"\n },\n {\n \"value\": \"500\",\n \"label\": \"500\"\n },\n {\n \"value\": \"600\",\n \"label\": \"600\"\n },\n {\n \"value\": \"700\",\n \"label\": \"800\"\n },\n {\n \"value\": \"800\",\n \"label\": \"800\"\n },\n {\n \"value\": \"900\",\n \"label\": \"900\"\n }\n ]\n },\n {\n \"key\": \"unitsFont.color\",\n \"type\": \"color\"\n }\n ]\n },\n {\n \"key\": \"valueFont\",\n \"items\": [\n \"valueFont.family\",\n \"valueFont.size\",\n {\n \"key\": \"valueFont.style\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"italic\",\n \"label\": \"Italic\"\n },\n {\n \"value\": \"oblique\",\n \"label\": \"Oblique\"\n }\n ]\n },\n {\n \"key\": \"valueFont.weight\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"bold\",\n \"label\": \"Bold\"\n },\n {\n \"value\": \"bolder\",\n \"label\": \"Bolder\"\n },\n {\n \"value\": \"lighter\",\n \"label\": \"Lighter\"\n },\n {\n \"value\": \"100\",\n \"label\": \"100\"\n },\n {\n \"value\": \"200\",\n \"label\": \"200\"\n },\n {\n \"value\": \"300\",\n \"label\": \"300\"\n },\n {\n \"value\": \"400\",\n \"label\": \"400\"\n },\n {\n \"value\": \"500\",\n \"label\": \"500\"\n },\n {\n \"value\": \"600\",\n \"label\": \"600\"\n },\n {\n \"value\": \"700\",\n \"label\": \"800\"\n },\n {\n \"value\": \"800\",\n \"label\": \"800\"\n },\n {\n \"value\": \"900\",\n \"label\": \"900\"\n }\n ]\n },\n {\n \"key\": \"valueFont.color\",\n \"type\": \"color\"\n },\n {\n \"key\": \"valueFont.shadowColor\",\n \"type\": \"color\"\n }\n ]\n }, \n \"animation\",\n \"animationDuration\",\n {\n \"key\": \"animationRule\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"linear\",\n \"label\": \"Linear\"\n },\n {\n \"value\": \"quad\",\n \"label\": \"Quad\"\n },\n {\n \"value\": \"quint\",\n \"label\": \"Quint\"\n },\n {\n \"value\": \"cycle\",\n \"label\": \"Cycle\"\n },\n {\n \"value\": \"bounce\",\n \"label\": \"Bounce\"\n },\n {\n \"value\": \"elastic\",\n \"label\": \"Elastic\"\n },\n {\n \"value\": \"dequad\",\n \"label\": \"Dequad\"\n },\n {\n \"value\": \"dequint\",\n \"label\": \"Dequint\"\n },\n {\n \"value\": \"decycle\",\n \"label\": \"Decycle\"\n },\n {\n \"value\": \"debounce\",\n \"label\": \"Debounce\"\n },\n {\n \"value\": \"delastic\",\n \"label\": \"Delastic\"\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"maxValue\":60,\"startAngle\":67.5,\"ticksAngle\":225,\"showBorder\":true,\"defaultColor\":\"#e65100\",\"needleCircleSize\":7,\"highlights\":[{\"from\":-60,\"to\":-50,\"color\":\"#42a5f5\"},{\"from\":-50,\"to\":-40,\"color\":\"rgba(66, 165, 245, 0.83)\"},{\"from\":-40,\"to\":-30,\"color\":\"rgba(66, 165, 245, 0.66)\"},{\"from\":-30,\"to\":-20,\"color\":\"rgba(66, 165, 245, 0.5)\"},{\"from\":-20,\"to\":-10,\"color\":\"rgba(66, 165, 245, 0.33)\"},{\"from\":-10,\"to\":0,\"color\":\"rgba(66, 165, 245, 0.16)\"},{\"from\":0,\"to\":10,\"color\":\"rgba(229, 115, 115, 0.16)\"},{\"from\":10,\"to\":20,\"color\":\"rgba(229, 115, 115, 0.33)\"},{\"from\":20,\"to\":30,\"color\":\"rgba(229, 115, 115, 0.5)\"},{\"from\":30,\"to\":40,\"color\":\"rgba(229, 115, 115, 0.66)\"},{\"from\":40,\"to\":50,\"color\":\"rgba(229, 115, 115, 0.83)\"},{\"from\":50,\"to\":60,\"color\":\"#e57373\"}],\"showUnitTitle\":true,\"colorPlate\":\"#cfd8dc\",\"colorMajorTicks\":\"#444\",\"colorMinorTicks\":\"#666\",\"minorTicks\":2,\"valueInt\":3,\"valueDec\":1,\"highlightsWidth\":15,\"valueBox\":true,\"animation\":true,\"animationDuration\":1000,\"animationRule\":\"bounce\",\"colorNeedleShadowUp\":\"rgba(2, 255, 255, 0)\",\"colorNeedleShadowDown\":\"rgba(188, 143, 143, 0.78)\",\"units\":\"°C\",\"majorTicksCount\":12,\"numbersFont\":{\"family\":\"RobotoDraft\",\"size\":20,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#263238\"},\"titleFont\":{\"family\":\"RobotoDraft\",\"size\":24,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#263238\"},\"unitsFont\":{\"family\":\"RobotoDraft\",\"size\":28,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"size\":30,\"style\":\"normal\",\"weight\":\"normal\",\"shadowColor\":\"rgba(0, 0, 0, 0.49)\",\"color\":\"#444\"},\"colorValueBoxRect\":\"#888\",\"colorValueBoxRectEnd\":\"#666\",\"colorValueBoxBackground\":\"#babab2\",\"colorValueBoxShadow\":\"rgba(0,0,0,1)\",\"unitTitle\":\"Temperature\",\"minValue\":-60},\"title\":\"Temperature radial gauge - Canvas Gauges\"}"}',
213 218 'Temperature radial gauge - Canvas Gauges' );
... ...
... ... @@ -175,6 +175,23 @@ public class SubscriptionManager {
175 175 }
176 176 }
177 177
  178 + public void onAttributesUpdateFromServer(PluginContext ctx, DeviceId deviceId, String scope, List<AttributeKvEntry> attributes) {
  179 + Optional<ServerAddress> serverAddress = ctx.resolve(deviceId);
  180 + if (!serverAddress.isPresent()) {
  181 + onLocalSubscriptionUpdate(ctx, deviceId, SubscriptionType.ATTRIBUTES, s -> {
  182 + List<TsKvEntry> subscriptionUpdate = new ArrayList<TsKvEntry>();
  183 + for (AttributeKvEntry kv : attributes) {
  184 + if (s.isAllKeys() || s.getKeyStates().containsKey(kv.getKey())) {
  185 + subscriptionUpdate.add(new BasicTsKvEntry(kv.getLastUpdateTs(), kv));
  186 + }
  187 + }
  188 + return subscriptionUpdate;
  189 + });
  190 + } else {
  191 + rpcHandler.onAttributesUpdate(ctx, serverAddress.get(), deviceId, scope, attributes);
  192 + }
  193 + }
  194 +
178 195 private void updateSubscriptionState(String sessionId, Subscription subState, SubscriptionUpdate update) {
179 196 log.trace("[{}] updating subscription state {} using onUpdate {}", sessionId, subState, update);
180 197 update.getLatestValues().entrySet().forEach(e -> subState.setKeyState(e.getKey(), e.getValue()));
... ...
... ... @@ -43,7 +43,7 @@ public class TelemetryStoragePlugin extends AbstractPlugin<EmptyComponentConfigu
43 43
44 44 public TelemetryStoragePlugin() {
45 45 this.subscriptionManager = new SubscriptionManager();
46   - this.restMsgHandler = new TelemetryRestMsgHandler();
  46 + this.restMsgHandler = new TelemetryRestMsgHandler(subscriptionManager);
47 47 this.ruleMsgHandler = new TelemetryRuleMsgHandler(subscriptionManager);
48 48 this.websocketMsgHandler = new TelemetryWebsocketMsgHandler(subscriptionManager);
49 49 this.rpcMsgHandler = new TelemetryRpcMsgHandler(subscriptionManager);
... ...
... ... @@ -24,10 +24,6 @@ import org.thingsboard.server.extensions.core.plugin.telemetry.sub.SubscriptionT
24 24 @NoArgsConstructor
25 25 public class AttributesSubscriptionCmd extends SubscriptionCmd {
26 26
27   - public AttributesSubscriptionCmd(int cmdId, String deviceId, String keys, boolean unsubscribe) {
28   - super(cmdId, deviceId, keys, unsubscribe);
29   - }
30   -
31 27 @Override
32 28 public SubscriptionType getType() {
33 29 return SubscriptionType.ATTRIBUTES;
... ...
... ... @@ -26,6 +26,7 @@ public abstract class SubscriptionCmd implements TelemetryPluginCmd {
26 26 private int cmdId;
27 27 private String deviceId;
28 28 private String keys;
  29 + private String scope;
29 30 private boolean unsubscribe;
30 31
31 32 public abstract SubscriptionType getType();
... ... @@ -62,6 +63,14 @@ public abstract class SubscriptionCmd implements TelemetryPluginCmd {
62 63 this.unsubscribe = unsubscribe;
63 64 }
64 65
  66 + public String getScope() {
  67 + return scope;
  68 + }
  69 +
  70 + public void setKeys(String keys) {
  71 + this.keys = keys;
  72 + }
  73 +
65 74 @Override
66 75 public String toString() {
67 76 return "SubscriptionCmd [deviceId=" + deviceId + ", tags=" + keys + ", unsubscribe=" + unsubscribe + "]";
... ...
... ... @@ -26,11 +26,6 @@ public class TimeseriesSubscriptionCmd extends SubscriptionCmd {
26 26
27 27 private long timeWindow;
28 28
29   - public TimeseriesSubscriptionCmd(int cmdId, String deviceId, String keys, boolean unsubscribe, long timeWindow) {
30   - super(cmdId, deviceId, keys, unsubscribe);
31   - this.timeWindow = timeWindow;
32   - }
33   -
34 29 public long getTimeWindow() {
35 30 return timeWindow;
36 31 }
... ...
... ... @@ -29,6 +29,7 @@ import org.thingsboard.server.extensions.api.plugins.handlers.DefaultRestMsgHand
29 29 import org.thingsboard.server.extensions.api.plugins.rest.PluginRestMsg;
30 30 import org.thingsboard.server.extensions.api.plugins.rest.RestRequest;
31 31 import org.thingsboard.server.extensions.core.plugin.telemetry.AttributeData;
  32 +import org.thingsboard.server.extensions.core.plugin.telemetry.SubscriptionManager;
32 33 import org.thingsboard.server.extensions.core.plugin.telemetry.TsData;
33 34
34 35 import javax.servlet.ServletException;
... ... @@ -39,6 +40,12 @@ import java.util.stream.Collectors;
39 40 @Slf4j
40 41 public class TelemetryRestMsgHandler extends DefaultRestMsgHandler {
41 42
  43 + private final SubscriptionManager subscriptionManager;
  44 +
  45 + public TelemetryRestMsgHandler(SubscriptionManager subscriptionManager) {
  46 + this.subscriptionManager = subscriptionManager;
  47 + }
  48 +
42 49 @Override
43 50 public void handleHttpGetRequest(PluginContext ctx, PluginRestMsg msg) throws ServletException {
44 51 RestRequest request = msg.getRequest();
... ... @@ -74,9 +81,8 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler {
74 81 if (!StringUtils.isEmpty(scope)) {
75 82 attributes = ctx.loadAttributes(deviceId, scope);
76 83 } else {
77   - attributes = ctx.loadAttributes(deviceId, DataConstants.CLIENT_SCOPE);
78   - attributes.addAll(ctx.loadAttributes(deviceId, DataConstants.SERVER_SCOPE));
79   - attributes.addAll(ctx.loadAttributes(deviceId, DataConstants.SHARED_SCOPE));
  84 + attributes = new ArrayList<>();
  85 + Arrays.stream(DataConstants.ALL_SCOPES).forEach(s -> attributes.addAll(ctx.loadAttributes(deviceId, s)));
80 86 }
81 87 List<String> keys = attributes.stream().map(attrKv -> attrKv.getKey()).collect(Collectors.toList());
82 88 msg.getResponseHolder().setResult(new ResponseEntity<>(keys, HttpStatus.OK));
... ... @@ -99,9 +105,8 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler {
99 105 if (!StringUtils.isEmpty(scope)) {
100 106 attributes = getAttributeKvEntries(ctx, scope, deviceId, keys);
101 107 } else {
102   - attributes = getAttributeKvEntries(ctx, DataConstants.CLIENT_SCOPE, deviceId, keys);
103   - attributes.addAll(getAttributeKvEntries(ctx, DataConstants.SHARED_SCOPE, deviceId, keys));
104   - attributes.addAll(getAttributeKvEntries(ctx, DataConstants.SERVER_SCOPE, deviceId, keys));
  108 + attributes = new ArrayList<>();
  109 + Arrays.stream(DataConstants.ALL_SCOPES).forEach(s -> attributes.addAll(getAttributeKvEntries(ctx, s, deviceId, keys)));
105 110 }
106 111 List<AttributeData> values = attributes.stream().map(attribute -> new AttributeData(attribute.getLastUpdateTs(),
107 112 attribute.getKey(), attribute.getValue())).collect(Collectors.toList());
... ... @@ -145,6 +150,7 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler {
145 150 @Override
146 151 public void onSuccess(PluginContext ctx, Void value) {
147 152 msg.getResponseHolder().setResult(new ResponseEntity<>(HttpStatus.OK));
  153 + subscriptionManager.onAttributesUpdateFromServer(ctx, deviceId, scope, attributes);
148 154 }
149 155
150 156 @Override
... ... @@ -172,7 +178,8 @@ public class TelemetryRestMsgHandler extends DefaultRestMsgHandler {
172 178 DeviceId deviceId = DeviceId.fromString(pathParams[0]);
173 179 String scope = pathParams[1];
174 180 if (DataConstants.SERVER_SCOPE.equals(scope) ||
175   - DataConstants.SHARED_SCOPE.equals(scope)) {
  181 + DataConstants.SHARED_SCOPE.equals(scope) ||
  182 + DataConstants.CLIENT_SCOPE.equals(scope)) {
176 183 String keysParam = request.getParameter("keys");
177 184 if (!StringUtils.isEmpty(keysParam)) {
178 185 String[] keys = keysParam.split(",");
... ...
... ... @@ -19,6 +19,7 @@ import com.google.protobuf.InvalidProtocolBufferException;
19 19 import lombok.RequiredArgsConstructor;
20 20 import lombok.extern.slf4j.Slf4j;
21 21 import org.thingsboard.server.common.data.id.DeviceId;
  22 +import org.thingsboard.server.common.data.kv.*;
22 23 import org.thingsboard.server.common.msg.cluster.ServerAddress;
23 24 import org.thingsboard.server.extensions.api.plugins.PluginContext;
24 25 import org.thingsboard.server.extensions.api.plugins.handlers.RpcMsgHandler;
... ... @@ -42,9 +43,10 @@ public class TelemetryRpcMsgHandler implements RpcMsgHandler {
42 43 private final SubscriptionManager subscriptionManager;
43 44
44 45 private static final int SUBSCRIPTION_CLAZZ = 1;
45   - private static final int SUBSCRIPTION_UPDATE_CLAZZ = 2;
46   - private static final int SESSION_CLOSE_CLAZZ = 3;
47   - private static final int SUBSCRIPTION_CLOSE_CLAZZ = 4;
  46 + private static final int ATTRIBUTES_UPDATE_CLAZZ = 2;
  47 + private static final int SUBSCRIPTION_UPDATE_CLAZZ = 3;
  48 + private static final int SESSION_CLOSE_CLAZZ = 4;
  49 + private static final int SUBSCRIPTION_CLOSE_CLAZZ = 5;
48 50
49 51 @Override
50 52 public void process(PluginContext ctx, RpcMsg msg) {
... ... @@ -55,6 +57,9 @@ public class TelemetryRpcMsgHandler implements RpcMsgHandler {
55 57 case SUBSCRIPTION_UPDATE_CLAZZ:
56 58 processRemoteSubscriptionUpdate(ctx, msg);
57 59 break;
  60 + case ATTRIBUTES_UPDATE_CLAZZ:
  61 + processAttributeUpdate(ctx, msg);
  62 + break;
58 63 case SESSION_CLOSE_CLAZZ:
59 64 processSessionClose(ctx, msg);
60 65 break;
... ... @@ -76,6 +81,17 @@ public class TelemetryRpcMsgHandler implements RpcMsgHandler {
76 81 subscriptionManager.onRemoteSubscriptionUpdate(ctx, proto.getSessionId(), convert(proto));
77 82 }
78 83
  84 + private void processAttributeUpdate(PluginContext ctx, RpcMsg msg) {
  85 + AttributeUpdateProto proto;
  86 + try {
  87 + proto = AttributeUpdateProto.parseFrom(msg.getMsgData());
  88 + } catch (InvalidProtocolBufferException e) {
  89 + throw new RuntimeException(e);
  90 + }
  91 + subscriptionManager.onAttributesUpdateFromServer(ctx, DeviceId.fromString(proto.getDeviceId()), proto.getScope(),
  92 + proto.getDataList().stream().map(this::toAttribute).collect(Collectors.toList()));
  93 + }
  94 +
79 95 private void processSubscriptionCmd(PluginContext ctx, RpcMsg msg) {
80 96 SubscriptionProto proto;
81 97 try {
... ... @@ -167,11 +183,7 @@ public class TelemetryRpcMsgHandler implements RpcMsgHandler {
167 183 } else {
168 184 Map<String, List<Object>> data = new TreeMap<>();
169 185 proto.getDataList().forEach(v -> {
170   - List<Object> values = data.get(v.getKey());
171   - if (values == null) {
172   - values = new ArrayList<>();
173   - data.put(v.getKey(), values);
174   - }
  186 + List<Object> values = data.computeIfAbsent(v.getKey(), k -> new ArrayList<>());
175 187 for (int i = 0; i < v.getTsCount(); i++) {
176 188 Object[] value = new Object[2];
177 189 value[0] = v.getTs(i);
... ... @@ -182,4 +194,59 @@ public class TelemetryRpcMsgHandler implements RpcMsgHandler {
182 194 return new SubscriptionUpdate(proto.getSubscriptionId(), data);
183 195 }
184 196 }
  197 +
  198 + public void onAttributesUpdate(PluginContext ctx, ServerAddress address, DeviceId deviceId, String scope, List<AttributeKvEntry> attributes) {
  199 + ctx.sendPluginRpcMsg(new RpcMsg(address, ATTRIBUTES_UPDATE_CLAZZ, getAttributesUpdateProto(deviceId, scope, attributes).toByteArray()));
  200 + }
  201 +
  202 + private AttributeUpdateProto getAttributesUpdateProto(DeviceId deviceId, String scope, List<AttributeKvEntry> attributes) {
  203 + AttributeUpdateProto.Builder builder = AttributeUpdateProto.newBuilder();
  204 + builder.setDeviceId(deviceId.toString());
  205 + builder.setScope(scope);
  206 + attributes.forEach(
  207 + attr -> {
  208 + AttributeUpdateValueListProto.Builder dataBuilder = AttributeUpdateValueListProto.newBuilder();
  209 + dataBuilder.setKey(attr.getKey());
  210 + dataBuilder.setTs(attr.getLastUpdateTs());
  211 + dataBuilder.setValueType(attr.getDataType().ordinal());
  212 + switch (attr.getDataType()) {
  213 + case BOOLEAN:
  214 + dataBuilder.setBoolValue(attr.getBooleanValue().get());
  215 + break;
  216 + case LONG:
  217 + dataBuilder.setLongValue(attr.getLongValue().get());
  218 + break;
  219 + case DOUBLE:
  220 + dataBuilder.setDoubleValue(attr.getDoubleValue().get());
  221 + break;
  222 + case STRING:
  223 + dataBuilder.setStrValue(attr.getStrValue().get());
  224 + break;
  225 + }
  226 + builder.addData(dataBuilder.build());
  227 + }
  228 + );
  229 + return builder.build();
  230 + }
  231 +
  232 + private AttributeKvEntry toAttribute(AttributeUpdateValueListProto proto) {
  233 + KvEntry entry = null;
  234 + DataType type = DataType.values()[proto.getValueType()];
  235 + switch (type) {
  236 + case BOOLEAN:
  237 + entry = new BooleanDataEntry(proto.getKey(), proto.getBoolValue());
  238 + break;
  239 + case LONG:
  240 + entry = new LongDataEntry(proto.getKey(), proto.getLongValue());
  241 + break;
  242 + case DOUBLE:
  243 + entry = new DoubleDataEntry(proto.getKey(), proto.getDoubleValue());
  244 + break;
  245 + case STRING:
  246 + entry = new StringDataEntry(proto.getKey(), proto.getStrValue());
  247 + break;
  248 + }
  249 + return new BaseAttributeKvEntry(entry, proto.getTs());
  250 + }
  251 +
185 252 }
... ...
... ... @@ -58,10 +58,14 @@ public class TelemetryRuleMsgHandler extends DefaultRuleMsgHandler {
58 58 ctx.reply(new ResponsePluginToRuleMsg(msg.getUid(), tenantId, ruleId, response));
59 59 }
60 60
61   - private List<AttributeKvEntry> getAttributeKvEntries(PluginContext ctx, DeviceId deviceId, String scope, Set<String> names) {
  61 + private List<AttributeKvEntry> getAttributeKvEntries(PluginContext ctx, DeviceId deviceId, String scope, Optional<Set<String>> names) {
62 62 List<AttributeKvEntry> attributes;
63   - if (!names.isEmpty()) {
64   - attributes = ctx.loadAttributes(deviceId, scope, new ArrayList<>(names));
  63 + if (names.isPresent()) {
  64 + if (!names.get().isEmpty()) {
  65 + attributes = ctx.loadAttributes(deviceId, scope, new ArrayList<>(names.get()));
  66 + } else {
  67 + attributes = ctx.loadAttributes(deviceId, scope);
  68 + }
65 69 } else {
66 70 attributes = Collections.emptyList();
67 71 }
... ...
... ... @@ -104,7 +104,13 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler {
104 104 SubscriptionState sub;
105 105 if (keysOptional.isPresent()) {
106 106 List<String> keys = new ArrayList<>(keysOptional.get());
107   - List<AttributeKvEntry> data = ctx.loadAttributes(deviceId, DataConstants.CLIENT_SCOPE, keys);
  107 + List<AttributeKvEntry> data = new ArrayList<>();
  108 + if (StringUtils.isEmpty(cmd.getScope())) {
  109 + Arrays.stream(DataConstants.ALL_SCOPES).forEach(s -> data.addAll(ctx.loadAttributes(deviceId, s, keys)));
  110 + } else {
  111 + data.addAll(ctx.loadAttributes(deviceId, cmd.getScope(), keys));
  112 + }
  113 +
108 114 List<TsKvEntry> attributesData = data.stream().map(d -> new BasicTsKvEntry(d.getLastUpdateTs(), d)).collect(Collectors.toList());
109 115 sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), attributesData));
110 116
... ... @@ -114,7 +120,12 @@ public class TelemetryWebsocketMsgHandler extends DefaultWebsocketMsgHandler {
114 120
115 121 sub = new SubscriptionState(sessionId, cmd.getCmdId(), deviceId, SubscriptionType.ATTRIBUTES, false, subState);
116 122 } else {
117   - List<AttributeKvEntry> data = ctx.loadAttributes(deviceId, DataConstants.CLIENT_SCOPE);
  123 + List<AttributeKvEntry> data = new ArrayList<>();
  124 + if (StringUtils.isEmpty(cmd.getScope())) {
  125 + Arrays.stream(DataConstants.ALL_SCOPES).forEach(s -> data.addAll(ctx.loadAttributes(deviceId, s)));
  126 + } else {
  127 + data.addAll(ctx.loadAttributes(deviceId, cmd.getScope()));
  128 + }
118 129 List<TsKvEntry> attributesData = data.stream().map(d -> new BasicTsKvEntry(d.getLastUpdateTs(), d)).collect(Collectors.toList());
119 130 sendWsMsg(ctx, sessionRef, new SubscriptionUpdate(cmd.getCmdId(), attributesData));
120 131
... ...
... ... @@ -36,6 +36,12 @@ message SubscriptionUpdateProto {
36 36 repeated SubscriptionUpdateValueListProto data = 5;
37 37 }
38 38
  39 +message AttributeUpdateProto {
  40 + string deviceId = 1;
  41 + string scope = 2;
  42 + repeated AttributeUpdateValueListProto data = 3;
  43 +}
  44 +
39 45 message SessionCloseProto {
40 46 string sessionId = 1;
41 47 }
... ... @@ -54,4 +60,14 @@ message SubscriptionUpdateValueListProto {
54 60 string key = 1;
55 61 repeated int64 ts = 2;
56 62 repeated string value = 3;
  63 +}
  64 +
  65 +message AttributeUpdateValueListProto {
  66 + string key = 1;
  67 + int64 ts = 2;
  68 + int32 valueType = 3;
  69 + string strValue = 4;
  70 + int64 longValue = 5;
  71 + double doubleValue = 6;
  72 + bool boolValue = 7;
57 73 }
\ No newline at end of file
... ...
... ... @@ -167,17 +167,13 @@ public class JsonCoapAdaptor implements CoapTransportAdaptor {
167 167
168 168 private FromDeviceMsg convertToGetAttributesRequest(SessionContext ctx, Request inbound) throws AdaptorException {
169 169 List<String> queryElements = inbound.getOptions().getUriQuery();
170   - if (queryElements == null || queryElements.size() == 0) {
171   - log.warn("[{}] Query is empty!", ctx.getSessionId());
172   - throw new AdaptorException(new IllegalArgumentException("Query is empty!"));
173   - }
174   -
175   - Set<String> clientKeys = toKeys(ctx, queryElements, "clientKeys");
176   - Set<String> sharedKeys = toKeys(ctx, queryElements, "sharedKeys");
177   - if (clientKeys.isEmpty() && sharedKeys.isEmpty()) {
178   - throw new AdaptorException("No clientKeys and serverKeys parameters!");
  170 + if (queryElements != null || queryElements.size() > 0) {
  171 + Set<String> clientKeys = toKeys(ctx, queryElements, "clientKeys");
  172 + Set<String> sharedKeys = toKeys(ctx, queryElements, "sharedKeys");
  173 + return new BasicGetAttributesRequest(0, clientKeys, sharedKeys);
  174 + } else {
  175 + return new BasicGetAttributesRequest(0);
179 176 }
180   - return new BasicGetAttributesRequest(0, clientKeys, sharedKeys);
181 177 }
182 178
183 179 private Set<String> toKeys(SessionContext ctx, List<String> queryElements, String attributeName) throws AdaptorException {
... ... @@ -191,7 +187,7 @@ public class JsonCoapAdaptor implements CoapTransportAdaptor {
191 187 if (!StringUtils.isEmpty(keys)) {
192 188 return new HashSet<>(Arrays.asList(keys.split(",")));
193 189 } else {
194   - return Collections.emptySet();
  190 + return null;
195 191 }
196 192 }
197 193
... ...
... ... @@ -182,7 +182,7 @@ public class CoapServerTest {
182 182 public void testNoKeysAttributesGetRequest() {
183 183 CoapClient client = new CoapClient(getBaseTestUrl() + DEVICE1_TOKEN + "/" + FeatureType.ATTRIBUTES.name().toLowerCase() + "?data=key1,key2");
184 184 CoapResponse response = client.setTimeout(6000).get();
185   - Assert.assertEquals(ResponseCode.BAD_REQUEST, response.getCode());
  185 + Assert.assertEquals(ResponseCode.CONTENT, response.getCode());
186 186 }
187 187
188 188 @Test
... ...
... ... @@ -38,6 +38,7 @@ import org.thingsboard.server.common.transport.auth.DeviceAuthService;
38 38 import org.thingsboard.server.transport.http.session.HttpSessionCtx;
39 39
40 40 import java.util.Arrays;
  41 +import java.util.Collections;
41 42 import java.util.HashSet;
42 43 import java.util.Set;
43 44
... ... @@ -60,20 +61,22 @@ public class DeviceApiController {
60 61
61 62 @RequestMapping(value = "/{deviceToken}/attributes", method = RequestMethod.GET, produces = "application/json")
62 63 public DeferredResult<ResponseEntity> getDeviceAttributes(@PathVariable("deviceToken") String deviceToken,
63   - @RequestParam(value = "clientKeys", required = false) String clientKeys,
64   - @RequestParam(value = "sharedKeys", required = false) String sharedKeys) {
  64 + @RequestParam(value = "clientKeys", required = false, defaultValue = "") String clientKeys,
  65 + @RequestParam(value = "sharedKeys", required = false, defaultValue = "") String sharedKeys) {
65 66 DeferredResult<ResponseEntity> responseWriter = new DeferredResult<ResponseEntity>();
66   - if (StringUtils.isEmpty(clientKeys) && StringUtils.isEmpty(sharedKeys)) {
67   - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST));
68   - } else {
69   - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter);
70   - if (ctx.login(new DeviceTokenCredentials(deviceToken))) {
71   - Set<String> clientKeySet = new HashSet<>(Arrays.asList(clientKeys.split(",")));
72   - Set<String> sharedKeySet = new HashSet<>(Arrays.asList(clientKeys.split(",")));
73   - process(ctx, new BasicGetAttributesRequest(0, clientKeySet, sharedKeySet));
  67 + HttpSessionCtx ctx = getHttpSessionCtx(responseWriter);
  68 + if (ctx.login(new DeviceTokenCredentials(deviceToken))) {
  69 + GetAttributesRequest request;
  70 + if (StringUtils.isEmpty(clientKeys) && StringUtils.isEmpty(sharedKeys)) {
  71 + request = new BasicGetAttributesRequest(0);
74 72 } else {
75   - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED));
  73 + Set<String> clientKeySet = !StringUtils.isEmpty(clientKeys) ? new HashSet<>(Arrays.asList(clientKeys.split(","))) : null;
  74 + Set<String> sharedKeySet = !StringUtils.isEmpty(sharedKeys) ? new HashSet<>(Arrays.asList(sharedKeys.split(","))) : null;
  75 + request = new BasicGetAttributesRequest(0, clientKeySet, sharedKeySet);
76 76 }
  77 + process(ctx, request);
  78 + } else {
  79 + responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED));
77 80 }
78 81
79 82 return responseWriter;
... ...
... ... @@ -162,8 +162,13 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor {
162 162 Integer requestId = Integer.valueOf(topicName.substring(MqttTransportHandler.ATTRIBUTES_REQUEST_TOPIC_PREFIX.length()));
163 163 String payload = inbound.payload().toString(UTF8);
164 164 JsonElement requestBody = new JsonParser().parse(payload);
165   - return new BasicGetAttributesRequest(requestId,
166   - toStringSet(requestBody, "clientKeys"), toStringSet(requestBody, "sharedKeys"));
  165 + Set<String> clientKeys = toStringSet(requestBody, "clientKeys");
  166 + Set<String> sharedKeys = toStringSet(requestBody, "sharedKeys");
  167 + if (clientKeys == null && sharedKeys == null) {
  168 + return new BasicGetAttributesRequest(requestId);
  169 + } else {
  170 + return new BasicGetAttributesRequest(requestId, clientKeys, sharedKeys);
  171 + }
167 172 } catch (RuntimeException e) {
168 173 log.warn("Failed to decode get attributes request", e);
169 174 throw new AdaptorException(e);
... ... @@ -189,7 +194,7 @@ public class JsonMqttAdaptor implements MqttTransportAdaptor {
189 194 if (element != null) {
190 195 return new HashSet<>(Arrays.asList(element.getAsString().split(",")));
191 196 } else {
192   - return Collections.emptySet();
  197 + return null;
193 198 }
194 199 }
195 200
... ...
... ... @@ -293,7 +293,8 @@ function DeviceService($http, $q, $filter, telemetryWebsocketService, types) {
293 293 var deviceAttributesSubscription = deviceAttributesSubscriptionMap[subscriptionId];
294 294 if (!deviceAttributesSubscription) {
295 295 var subscriptionCommand = {
296   - deviceId: deviceId
  296 + deviceId: deviceId,
  297 + scope: attributeScope
297 298 };
298 299
299 300 var type = attributeScope === types.latestTelemetry.value ?
... ...
... ... @@ -49,6 +49,7 @@ import thingsboardDialogs from './components/datakey-config-dialog.controller';
49 49 import thingsboardMenu from './services/menu.service';
50 50 import thingsboardUtils from './common/utils.service';
51 51 import thingsboardTypes from './common/types.constant';
  52 +import thingsboardKeyboardShortcut from './components/keyboard-shortcut.filter';
52 53 import thingsboardHelp from './help/help.directive';
53 54 import thingsboardToast from './services/toast';
54 55 import thingsboardHome from './layout';
... ... @@ -95,6 +96,7 @@ angular.module('thingsboard', [
95 96 thingsboardMenu,
96 97 thingsboardUtils,
97 98 thingsboardTypes,
  99 + thingsboardKeyboardShortcut,
98 100 thingsboardHelp,
99 101 thingsboardToast,
100 102 thingsboardHome,
... ...
... ... @@ -23,6 +23,7 @@ import thingsboardWidget from './widget.directive';
23 23 import thingsboardToast from '../services/toast';
24 24 import thingsboardTimewindow from './timewindow.directive';
25 25 import thingsboardEvents from './tb-event-directives';
  26 +import thingsboardMousepointMenu from './mousepoint-menu.directive';
26 27
27 28 /* eslint-disable import/no-unresolved, import/default */
28 29
... ... @@ -38,6 +39,7 @@ export default angular.module('thingsboard.directives.dashboard', [thingsboardTy
38 39 thingsboardWidget,
39 40 thingsboardTimewindow,
40 41 thingsboardEvents,
  42 + thingsboardMousepointMenu,
41 43 gridster.name])
42 44 .directive('tbDashboard', Dashboard)
43 45 .name;
... ... @@ -59,7 +61,10 @@ function Dashboard() {
59 61 isRemoveActionEnabled: '=',
60 62 onEditWidget: '&?',
61 63 onRemoveWidget: '&?',
  64 + onWidgetMouseDown: '&?',
62 65 onWidgetClicked: '&?',
  66 + prepareDashboardContextMenu: '&?',
  67 + prepareWidgetContextMenu: '&?',
63 68 loadWidgets: '&?',
64 69 onInit: '&?',
65 70 onInitFailed: '&?',
... ... @@ -75,8 +80,9 @@ function Dashboard() {
75 80 function DashboardController($scope, $rootScope, $element, $timeout, $log, toast, types) {
76 81
77 82 var highlightedMode = false;
78   - var highlightedIndex = -1;
79   - var mouseDownIndex = -1;
  83 + var highlightedWidget = null;
  84 + var selectedWidget = null;
  85 + var mouseDownWidget = -1;
80 86 var widgetMouseMoved = false;
81 87
82 88 var gridsterParent = null;
... ... @@ -117,6 +123,8 @@ function DashboardController($scope, $rootScope, $element, $timeout, $log, toast
117 123 vm.isWidgetExpanded = false;
118 124 vm.isHighlighted = isHighlighted;
119 125 vm.isNotHighlighted = isNotHighlighted;
  126 + vm.selectWidget = selectWidget;
  127 + vm.getSelectedWidget = getSelectedWidget;
120 128 vm.highlightWidget = highlightWidget;
121 129 vm.resetHighlight = resetHighlight;
122 130
... ... @@ -134,6 +142,17 @@ function DashboardController($scope, $rootScope, $element, $timeout, $log, toast
134 142 vm.removeWidget = removeWidget;
135 143 vm.loading = loading;
136 144
  145 + vm.openDashboardContextMenu = openDashboardContextMenu;
  146 + vm.openWidgetContextMenu = openWidgetContextMenu;
  147 +
  148 + vm.getEventGridPosition = getEventGridPosition;
  149 +
  150 + vm.contextMenuItems = [];
  151 + vm.contextMenuEvent = null;
  152 +
  153 + vm.widgetContextMenuItems = [];
  154 + vm.widgetContextMenuEvent = null;
  155 +
137 156 //$element[0].onmousemove=function(){
138 157 // widgetMouseMove();
139 158 // }
... ... @@ -305,7 +324,7 @@ function DashboardController($scope, $rootScope, $element, $timeout, $log, toast
305 324 }
306 325
307 326 function resetWidgetClick () {
308   - mouseDownIndex = -1;
  327 + mouseDownWidget = -1;
309 328 widgetMouseMoved = false;
310 329 }
311 330
... ... @@ -315,25 +334,27 @@ function DashboardController($scope, $rootScope, $element, $timeout, $log, toast
315 334 }
316 335
317 336 function widgetMouseDown ($event, widget) {
318   - mouseDownIndex = vm.widgets.indexOf(widget);
  337 + mouseDownWidget = widget;
319 338 widgetMouseMoved = false;
  339 + if (vm.onWidgetMouseDown) {
  340 + vm.onWidgetMouseDown({event: $event, widget: widget});
  341 + }
320 342 }
321 343
322 344 function widgetMouseMove () {
323   - if (mouseDownIndex > -1) {
  345 + if (mouseDownWidget) {
324 346 widgetMouseMoved = true;
325 347 }
326 348 }
327 349
328 350 function widgetMouseUp ($event, widget) {
329 351 $timeout(function () {
330   - if (!widgetMouseMoved && mouseDownIndex > -1) {
331   - var index = vm.widgets.indexOf(widget);
332   - if (index === mouseDownIndex) {
  352 + if (!widgetMouseMoved && mouseDownWidget) {
  353 + if (widget === mouseDownWidget) {
333 354 widgetClicked($event, widget);
334 355 }
335 356 }
336   - mouseDownIndex = -1;
  357 + mouseDownWidget = null;
337 358 widgetMouseMoved = false;
338 359 }, 0);
339 360 }
... ... @@ -347,6 +368,41 @@ function DashboardController($scope, $rootScope, $element, $timeout, $log, toast
347 368 }
348 369 }
349 370
  371 + function openDashboardContextMenu($event, $mdOpenMousepointMenu) {
  372 + if (vm.prepareDashboardContextMenu) {
  373 + vm.contextMenuItems = vm.prepareDashboardContextMenu();
  374 + if (vm.contextMenuItems && vm.contextMenuItems.length > 0) {
  375 + vm.contextMenuEvent = $event;
  376 + $mdOpenMousepointMenu($event);
  377 + }
  378 + }
  379 + }
  380 +
  381 + function openWidgetContextMenu($event, widget, $mdOpenMousepointMenu) {
  382 + if (vm.prepareWidgetContextMenu) {
  383 + vm.widgetContextMenuItems = vm.prepareWidgetContextMenu({widget: widget});
  384 + if (vm.widgetContextMenuItems && vm.widgetContextMenuItems.length > 0) {
  385 + vm.widgetContextMenuEvent = $event;
  386 + $mdOpenMousepointMenu($event);
  387 + }
  388 + }
  389 + }
  390 +
  391 + function getEventGridPosition(event) {
  392 + var pos = {
  393 + row: 0,
  394 + column: 0
  395 + }
  396 + var offset = gridsterParent.offset();
  397 + var x = event.pageX - offset.left + gridsterParent.scrollLeft();
  398 + var y = event.pageY - offset.top + gridsterParent.scrollTop();
  399 + if (gridster) {
  400 + pos.row = gridster.pixelsToRows(y);
  401 + pos.column = gridster.pixelsToColumns(x);
  402 + }
  403 + return pos;
  404 + }
  405 +
350 406 function editWidget ($event, widget) {
351 407 resetWidgetClick();
352 408 if ($event) {
... ... @@ -367,10 +423,10 @@ function DashboardController($scope, $rootScope, $element, $timeout, $log, toast
367 423 }
368 424 }
369 425
370   - function highlightWidget(widgetIndex, delay) {
  426 + function highlightWidget(widget, delay) {
371 427 highlightedMode = true;
372   - highlightedIndex = widgetIndex;
373   - var item = $('.gridster-item', gridster.$element)[widgetIndex];
  428 + highlightedWidget = widget;
  429 + var item = $('.gridster-item', gridster.$element)[vm.widgets.indexOf(widget)];
374 430 if (item) {
375 431 var height = $(item).outerHeight(true);
376 432 var rectHeight = gridsterParent.height();
... ... @@ -385,17 +441,39 @@ function DashboardController($scope, $rootScope, $element, $timeout, $log, toast
385 441 }
386 442 }
387 443
  444 + function selectWidget(widget, delay) {
  445 + selectedWidget = widget;
  446 + var item = $('.gridster-item', gridster.$element)[vm.widgets.indexOf(widget)];
  447 + if (item) {
  448 + var height = $(item).outerHeight(true);
  449 + var rectHeight = gridsterParent.height();
  450 + var offset = (rectHeight - height) / 2;
  451 + var scrollTop = item.offsetTop;
  452 + if (offset > 0) {
  453 + scrollTop -= offset;
  454 + }
  455 + gridsterParent.animate({
  456 + scrollTop: scrollTop
  457 + }, delay);
  458 + }
  459 + }
  460 +
  461 + function getSelectedWidget() {
  462 + return selectedWidget;
  463 + }
  464 +
388 465 function resetHighlight() {
389 466 highlightedMode = false;
390   - highlightedIndex = -1;
  467 + highlightedWidget = null;
  468 + selectedWidget = null;
391 469 }
392 470
393 471 function isHighlighted(widget) {
394   - return highlightedMode && vm.widgets.indexOf(widget) === highlightedIndex;
  472 + return (highlightedMode && highlightedWidget === widget) || (selectedWidget === widget);
395 473 }
396 474
397 475 function isNotHighlighted(widget) {
398   - return highlightedMode && vm.widgets.indexOf(widget) != highlightedIndex;
  476 + return highlightedMode && highlightedWidget != widget;
399 477 }
400 478
401 479 function widgetColor(widget) {
... ...
... ... @@ -20,6 +20,7 @@ div.tb-widget {
20 20 height: 100%;
21 21 margin: 0;
22 22 overflow: hidden;
  23 + outline: none;
23 24 @include transition(all .2s ease-in-out);
24 25
25 26 .tb-widget-title {
... ... @@ -91,6 +92,7 @@ md-content.tb-dashboard-content {
91 92 left: 0;
92 93 right: 0;
93 94 bottom: 0;
  95 + outline: none;
94 96 }
95 97
96 98 .tb-widget-error-container {
... ...
... ... @@ -19,64 +19,90 @@
19 19 ng-show="(vm.loading() || vm.dashboardLoading) && !vm.isEdit">
20 20 <md-progress-circular md-mode="indeterminate" class="md-warn" md-diameter="100"></md-progress-circular>
21 21 </md-content>
22   -<md-content id="gridster-parent" class="tb-dashboard-content" flex layout-wrap>
23   - <div ng-style="vm.dashboardStyle" id="gridster-background" style="height: auto; min-height: 100%;">
24   - <div id="gridster-child" gridster="vm.gridsterOpts">
25   - <ul>
26   - <!-- ng-click="widgetClicked($event, widget)" -->
27   - <li gridster-item="widget" ng-repeat="widget in vm.widgets">
28   - <div tb-expand-fullscreen expand-button-id="expand-button" on-fullscreen-changed="vm.onWidgetFullscreenChanged(expanded, widget)" layout="column" class="tb-widget md-whiteframe-4dp"
29   - ng-class="{'tb-highlighted': vm.isHighlighted(widget), 'tb-not-highlighted': vm.isNotHighlighted(widget)}"
30   - tb-mousedown="vm.widgetMouseDown($event, widget)"
31   - tb-mousemove="vm.widgetMouseMove($event, widget)"
32   - tb-mouseup="vm.widgetMouseUp($event, widget)"
33   - style="
34   - cursor: pointer;
35   - color: {{vm.widgetColor(widget)}};
36   - background-color: {{vm.widgetBackgroundColor(widget)}};
37   - padding: {{vm.widgetPadding(widget)}}
38   - ">
39   - <div class="tb-widget-title" layout="column" ng-show="vm.showWidgetTitle(widget) || vm.hasTimewindow(widget)">
40   - <span ng-show="vm.showWidgetTitle(widget)" class="md-subhead">{{widget.config.title}}</span>
41   - <tb-timewindow ng-if="vm.hasTimewindow(widget)" ng-model="widget.config.timewindow"></tb-timewindow>
42   - </div>
43   - <div class="tb-widget-actions" layout="row" layout-align="start center">
44   - <md-button id="expand-button"
45   - aria-label="{{ 'fullscreen.fullscreen' | translate }}"
46   - class="md-icon-button md-primary"></md-button>
47   - <md-button ng-show="vm.isEditActionEnabled && !vm.isWidgetExpanded"
48   - ng-disabled="vm.loading()"
49   - class="md-icon-button md-primary"
50   - ng-click="vm.editWidget($event, widget)"
51   - aria-label="{{ 'widget.edit' | translate }}">
52   - <md-tooltip md-direction="top">
53   - {{ 'widget.edit' | translate }}
54   - </md-tooltip>
55   - <md-icon class="material-icons">
56   - edit
57   - </md-icon>
58   - </md-button>
59   - <md-button ng-show="vm.isRemoveActionEnabled && !vm.isWidgetExpanded"
60   - ng-disabled="vm.loading()"
61   - class="md-icon-button md-primary"
62   - ng-click="vm.removeWidget($event, widget)"
63   - aria-label="{{ 'widget.remove' | translate }}">
64   - <md-tooltip md-direction="top">
65   - {{ 'widget.remove' | translate }}
66   - </md-tooltip>
67   - <md-icon class="material-icons">
68   - close
69   - </md-icon>
70   - </md-button>
71   - </div>
72   - <div flex layout="column" class="tb-widget-content">
73   - <div flex tb-widget
74   - locals="{ visibleRect: vm.visibleRect, widget: widget, deviceAliasList: vm.deviceAliasList, isPreview: vm.isEdit }">
  22 +<md-menu md-position-mode="target target" tb-mousepoint-menu>
  23 + <md-content id="gridster-parent" class="tb-dashboard-content" flex layout-wrap ng-click="" tb-contextmenu="vm.openDashboardContextMenu($event, $mdOpenMousepointMenu)">
  24 + <div ng-style="vm.dashboardStyle" id="gridster-background" style="height: auto; min-height: 100%;">
  25 + <div id="gridster-child" gridster="vm.gridsterOpts">
  26 + <ul>
  27 + <!-- ng-click="widgetClicked($event, widget)" -->
  28 + <li gridster-item="widget" ng-repeat="widget in vm.widgets">
  29 + <md-menu md-position-mode="target target" tb-mousepoint-menu>
  30 + <div tb-expand-fullscreen
  31 + expand-button-id="expand-button" on-fullscreen-changed="vm.onWidgetFullscreenChanged(expanded, widget)" layout="column" class="tb-widget md-whiteframe-4dp"
  32 + ng-class="{'tb-highlighted': vm.isHighlighted(widget), 'tb-not-highlighted': vm.isNotHighlighted(widget)}"
  33 + tb-mousedown="vm.widgetMouseDown($event, widget)"
  34 + tb-mousemove="vm.widgetMouseMove($event, widget)"
  35 + tb-mouseup="vm.widgetMouseUp($event, widget)"
  36 + ng-click=""
  37 + tb-contextmenu="vm.openWidgetContextMenu($event, widget, $mdOpenMousepointMenu)"
  38 + style="
  39 + cursor: pointer;
  40 + color: {{vm.widgetColor(widget)}};
  41 + background-color: {{vm.widgetBackgroundColor(widget)}};
  42 + padding: {{vm.widgetPadding(widget)}}
  43 + ">
  44 + <div class="tb-widget-title" layout="column" ng-show="vm.showWidgetTitle(widget) || vm.hasTimewindow(widget)">
  45 + <span ng-show="vm.showWidgetTitle(widget)" class="md-subhead">{{widget.config.title}}</span>
  46 + <tb-timewindow ng-if="vm.hasTimewindow(widget)" ng-model="widget.config.timewindow"></tb-timewindow>
  47 + </div>
  48 + <div class="tb-widget-actions" layout="row" layout-align="start center">
  49 + <md-button id="expand-button"
  50 + ng-show="!vm.isEdit"
  51 + aria-label="{{ 'fullscreen.fullscreen' | translate }}"
  52 + class="md-icon-button md-primary"></md-button>
  53 + <md-button ng-show="vm.isEditActionEnabled && !vm.isWidgetExpanded"
  54 + ng-disabled="vm.loading()"
  55 + class="md-icon-button md-primary"
  56 + ng-click="vm.editWidget($event, widget)"
  57 + aria-label="{{ 'widget.edit' | translate }}">
  58 + <md-tooltip md-direction="top">
  59 + {{ 'widget.edit' | translate }}
  60 + </md-tooltip>
  61 + <md-icon class="material-icons">
  62 + edit
  63 + </md-icon>
  64 + </md-button>
  65 + <md-button ng-show="vm.isRemoveActionEnabled && !vm.isWidgetExpanded"
  66 + ng-disabled="vm.loading()"
  67 + class="md-icon-button md-primary"
  68 + ng-click="vm.removeWidget($event, widget)"
  69 + aria-label="{{ 'widget.remove' | translate }}">
  70 + <md-tooltip md-direction="top">
  71 + {{ 'widget.remove' | translate }}
  72 + </md-tooltip>
  73 + <md-icon class="material-icons">
  74 + close
  75 + </md-icon>
  76 + </md-button>
  77 + </div>
  78 + <div flex layout="column" class="tb-widget-content">
  79 + <div flex tb-widget
  80 + locals="{ visibleRect: vm.visibleRect, widget: widget, deviceAliasList: vm.deviceAliasList, isPreview: vm.isEdit }">
  81 + </div>
  82 + </div>
75 83 </div>
76   - </div>
77   - </div>
78   - </li>
79   - </ul>
  84 + <md-menu-content id="menu" width="4" ng-mouseleave="$mdCloseMousepointMenu()">
  85 + <md-menu-item ng-repeat ="item in vm.widgetContextMenuItems">
  86 + <md-button ng-disabled="!item.enabled" ng-click="item.action(vm.widgetContextMenuEvent, widget)">
  87 + <md-icon ng-if="item.icon" md-menu-align-target aria-label="{{ item.value | translate }}" class="material-icons">{{item.icon}}</md-icon>
  88 + <span translate>{{item.value}}</span>
  89 + <span ng-if="item.shortcut" class="tb-alt-text"> {{ item.shortcut | keyboardShortcut }}</span>
  90 + </md-button>
  91 + </md-menu-item>
  92 + </md-menu-content>
  93 + </md-menu>
  94 + </li>
  95 + </ul>
  96 + </div>
80 97 </div>
81   - </div>
82   -</md-content>
\ No newline at end of file
  98 + </md-content>
  99 + <md-menu-content id="menu" width="4" ng-mouseleave="$mdCloseMousepointMenu()">
  100 + <md-menu-item ng-repeat ="item in vm.contextMenuItems">
  101 + <md-button ng-disabled="!item.enabled" ng-click="item.action(vm.contextMenuEvent)">
  102 + <md-icon ng-if="item.icon" md-menu-align-target aria-label="{{ item.value | translate }}" class="material-icons">{{item.icon}}</md-icon>
  103 + <span translate>{{item.value}}</span>
  104 + <span ng-if="item.shortcut" class="tb-alt-text"> {{ item.shortcut | keyboardShortcut }}</span>
  105 + </md-button>
  106 + </md-menu-item>
  107 + </md-menu-content>
  108 +</md-menu>
\ No newline at end of file
... ...
... ... @@ -13,6 +13,9 @@
13 13 * See the License for the specific language governing permissions and
14 14 * limitations under the License.
15 15 */
  16 +
  17 +@import '../../scss/constants';
  18 +
16 19 .tb-device-alias-autocomplete, .tb-timeseries-datakey-autocomplete, .tb-attribute-datakey-autocomplete {
17 20 .tb-not-found {
18 21 display: block;
... ... @@ -27,3 +30,16 @@
27 30 white-space: normal !important;
28 31 }
29 32 }
  33 +
  34 +tb-datasource-device {
  35 + @media (min-width: $layout-breakpoint-gt-sm) {
  36 + padding-left: 4px;
  37 + padding-right: 4px;
  38 + }
  39 + tb-device-alias-select {
  40 + @media (min-width: $layout-breakpoint-gt-sm) {
  41 + width: 200px;
  42 + max-width: 200px;
  43 + }
  44 + }
  45 +}
\ No newline at end of file
... ...
... ... @@ -15,16 +15,16 @@
15 15 limitations under the License.
16 16
17 17 -->
18   -<section flex layout='row' layout-align="start center">
19   - <tb-device-alias-select flex="40"
  18 +<section flex layout='column' layout-align="center" layout-gt-sm='row' layout-align-gt-sm="start center">
  19 + <tb-device-alias-select
20 20 tb-required="true"
21 21 device-aliases="deviceAliases"
22 22 ng-model="deviceAlias"
23 23 on-create-device-alias="onCreateDeviceAlias({event: event, alias: alias})">
24 24 </tb-device-alias-select>
25   - <section flex="120" layout='column'>
26   - <section flex layout='row' layout-align="start center">
27   - <md-chips flex style="padding-left: 4px;"
  25 + <section flex layout='column'>
  26 + <section flex layout='column' layout-align="center" style="padding-left: 4px;">
  27 + <md-chips flex
28 28 id="timeseries_datakey_chips"
29 29 ng-required="true"
30 30 ng-model="timeseriesDataKeys" md-autocomplete-snap
... ... @@ -56,14 +56,19 @@
56 56 </md-not-found>
57 57 </md-autocomplete>
58 58 <md-chip-template>
59   - <div layout="row" layout-align="start center">
  59 + <div layout="row" layout-align="start center" class="tb-attribute-chip">
60 60 <div class="tb-color-preview" ng-click="showColorPicker($event, $chip, $index)" style="margin-right: 5px;">
61 61 <div class="tb-color-result" ng-style="{background: $chip.color}"></div>
62 62 </div>
63   - <div>
64   - {{$chip.label}}:
65   - <strong ng-if="!$chip.postFuncBody">{{$chip.name}}</strong>
66   - <strong ng-if="$chip.postFuncBody">f({{$chip.name}})</strong>
  63 + <div layout="row" flex>
  64 + <div class="tb-chip-label">
  65 + {{$chip.label}}
  66 + </div>
  67 + <div class="tb-chip-separator">: </div>
  68 + <div class="tb-chip-label">
  69 + <strong ng-if="!$chip.postFuncBody">{{$chip.name}}</strong>
  70 + <strong ng-if="$chip.postFuncBody">f({{$chip.name}})</strong>
  71 + </div>
67 72 </div>
68 73 <md-button ng-click="editDataKey($event, $chip, $index)" class="md-icon-button tb-md-32">
69 74 <md-icon aria-label="edit" class="material-icons tb-md-20">edit</md-icon>
... ... @@ -71,7 +76,7 @@
71 76 </div>
72 77 </md-chip-template>
73 78 </md-chips>
74   - <md-chips flex ng-if="widgetType === types.widgetType.latest.value" style="padding-left: 4px;"
  79 + <md-chips flex ng-if="widgetType === types.widgetType.latest.value"
75 80 id="attribute_datakey_chips"
76 81 ng-required="true"
77 82 ng-model="attributeDataKeys" md-autocomplete-snap
... ... @@ -103,19 +108,24 @@
103 108 </md-not-found>
104 109 </md-autocomplete>
105 110 <md-chip-template>
106   - <div layout="row" layout-align="start center">
107   - <div class="tb-color-preview" ng-click="showColorPicker($event, $chip, $index)" style="margin-right: 5px;">
108   - <div class="tb-color-result" ng-style="{background: $chip.color}"></div>
109   - </div>
110   - <div>
111   - {{$chip.label}}:
112   - <strong ng-if="!$chip.postFuncBody">{{$chip.name}}</strong>
113   - <strong ng-if="$chip.postFuncBody">f({{$chip.name}})</strong>
114   - </div>
115   - <md-button ng-click="editDataKey($event, $chip, $index)" class="md-icon-button tb-md-32">
116   - <md-icon aria-label="edit" class="material-icons tb-md-20">edit</md-icon>
117   - </md-button>
118   - </div>
  111 + <div layout="row" layout-align="start center" class="tb-attribute-chip">
  112 + <div class="tb-color-preview" ng-click="showColorPicker($event, $chip, $index)" style="margin-right: 5px;">
  113 + <div class="tb-color-result" ng-style="{background: $chip.color}"></div>
  114 + </div>
  115 + <div layout="row" flex>
  116 + <div class="tb-chip-label">
  117 + {{$chip.label}}
  118 + </div>
  119 + <div class="tb-chip-separator">: </div>
  120 + <div class="tb-chip-label">
  121 + <strong ng-if="!$chip.postFuncBody">{{$chip.name}}</strong>
  122 + <strong ng-if="$chip.postFuncBody">f({{$chip.name}})</strong>
  123 + </div>
  124 + </div>
  125 + <md-button ng-click="editDataKey($event, $chip, $index)" class="md-icon-button tb-md-32">
  126 + <md-icon aria-label="edit" class="material-icons tb-md-20">edit</md-icon>
  127 + </md-button>
  128 + </div>
119 129 </md-chip-template>
120 130 </md-chips>
121 131 </section>
... ...
... ... @@ -13,6 +13,9 @@
13 13 * See the License for the specific language governing permissions and
14 14 * limitations under the License.
15 15 */
  16 +
  17 +@import '../../scss/constants';
  18 +
16 19 .tb-func-datakey-autocomplete {
17 20 .tb-not-found {
18 21 display: block;
... ... @@ -27,3 +30,9 @@
27 30 white-space: normal !important;
28 31 }
29 32 }
  33 +
  34 +tb-datasource-func {
  35 + @media (min-width: $layout-breakpoint-gt-sm) {
  36 + padding-left: 8px;
  37 + }
  38 +}
\ No newline at end of file
... ...
... ... @@ -15,8 +15,8 @@
15 15 limitations under the License.
16 16
17 17 -->
18   -<section flex layout='column'>
19   - <md-chips flex style="padding-left: 4px;"
  18 +<section flex layout='column' style="padding-left: 4px;">
  19 + <md-chips flex
20 20 id="function_datakey_chips"
21 21 ng-required="true"
22 22 ng-model="funcDataKeys" md-autocomplete-snap
... ... @@ -48,18 +48,23 @@
48 48 </md-not-found>
49 49 </md-autocomplete>
50 50 <md-chip-template>
51   - <div layout="row" layout-align="start center">
52   - <div class="tb-color-preview" ng-click="showColorPicker($event, $chip, $index)" style="margin-right: 5px;">
53   - <div class="tb-color-result" ng-style="{background: $chip.color}"></div>
54   - </div>
55   - <div>
56   - {{$chip.label}}:
57   - <strong>{{$chip.name}}</strong>
58   - </div>
59   - <md-button ng-click="editDataKey($event, $chip, $index)" class="md-icon-button tb-md-32">
60   - <md-icon aria-label="edit" class="material-icons tb-md-20">edit</md-icon>
61   - </md-button>
62   - </div>
  51 + <div layout="row" layout-align="start center" class="tb-attribute-chip">
  52 + <div class="tb-color-preview" ng-click="showColorPicker($event, $chip, $index)" style="margin-right: 5px;">
  53 + <div class="tb-color-result" ng-style="{background: $chip.color}"></div>
  54 + </div>
  55 + <div layout="row" flex>
  56 + <div class="tb-chip-label">
  57 + {{$chip.label}}
  58 + </div>
  59 + <div class="tb-chip-separator">: </div>
  60 + <div class="tb-chip-label">
  61 + <strong>{{$chip.name}}</strong>
  62 + </div>
  63 + </div>
  64 + <md-button ng-click="editDataKey($event, $chip, $index)" class="md-icon-button tb-md-32">
  65 + <md-icon aria-label="edit" class="material-icons tb-md-20">edit</md-icon>
  66 + </md-button>
  67 + </div>
63 68 </md-chip-template>
64 69 </md-chips>
65 70 <div class="tb-error-messages" ng-messages="ngModelCtrl.$error" role="alert">
... ...
... ... @@ -38,6 +38,7 @@
38 38
39 39 .tb-color-preview {
40 40 content: '';
  41 + min-width: 24px;
41 42 width: 24px;
42 43 height: 24px;
43 44 border: 2px solid #fff;
... ... @@ -52,3 +53,14 @@
52 53 height: 100%;
53 54 }
54 55 }
  56 +
  57 +.tb-attribute-chip {
  58 + .tb-chip-label {
  59 + overflow: hidden;
  60 + text-overflow: ellipsis;
  61 + white-space: nowrap;
  62 + }
  63 + .tb-chip-separator {
  64 + white-space: pre;
  65 + }
  66 +}
... ...
... ... @@ -15,7 +15,7 @@
15 15 limitations under the License.
16 16
17 17 -->
18   -<section flex layout='row' layout-align="start center" class="tb-datasource">
  18 +<section flex layout='column' layout-align="center" layout-gt-sm='row' layout-align-gt-sm="start center" class="tb-datasource">
19 19 <md-input-container style="min-width: 110px;">
20 20 <md-select placeholder="{{ 'datasource.type' | translate }}" required id="datasourceType" ng-model="model.type">
21 21 <md-option ng-repeat="datasourceType in datasourceTypes" value="{{datasourceType}}">
... ... @@ -23,15 +23,15 @@
23 23 </md-option>
24 24 </md-select>
25 25 </md-input-container>
26   - <section flex layout='row' layout-align="start center" class="datasource" ng-switch on="model.type">
27   - <tb-datasource-func flex style="padding-left: 8px;"
  26 + <section flex class="datasource" ng-switch on="model.type">
  27 + <tb-datasource-func flex
28 28 ng-switch-default
29 29 ng-model="model"
30 30 datakey-settings-schema="datakeySettingsSchema"
31 31 ng-required="model.type === types.datasourceType.function"
32 32 generate-data-key="generateDataKey({chip: chip, type: type})">
33 33 </tb-datasource-func>
34   - <tb-datasource-device flex style="padding-left: 4px; padding-right: 4px;"
  34 + <tb-datasource-device flex
35 35 ng-model="model"
36 36 datakey-settings-schema="datakeySettingsSchema"
37 37 ng-switch-when="device"
... ...
... ... @@ -20,15 +20,28 @@
20 20 font-weight: 400;
21 21 text-transform: uppercase;
22 22 margin: 20px 8px 0 0;
  23 + overflow: hidden;
  24 + text-overflow: ellipsis;
  25 + white-space: nowrap;
  26 + width: inherit;
23 27 }
24 28
25 29 .tb-details-subtitle {
26 30 font-size: 1.000rem;
27 31 margin: 10px 0;
28 32 opacity: 0.8;
  33 + overflow: hidden;
  34 + text-overflow: ellipsis;
  35 + white-space: nowrap;
  36 + width: inherit;
29 37 }
30 38
31 39 md-sidenav.tb-sidenav-details {
  40 + .md-toolbar-tools {
  41 + min-height: 100px;
  42 + max-height: 120px;
  43 + height: 100%;
  44 + }
32 45 width: 100% !important;
33 46 max-width: 100% !important;
34 47 z-index: 59 !important;
... ...
... ... @@ -22,13 +22,12 @@
22 22 layout="column">
23 23 <header>
24 24 <md-toolbar class="md-theme-light" ng-style="{'height':headerHeightPx+'px'}">
25   - <div class="md-toolbar-tools">
26   - <div class="md-toolbar-tools" layout="column" layout-align="start start">
  25 + <div class="md-toolbar-tools" layout="row">
  26 + <div flex class="md-toolbar-tools" layout="column" layout-align="start start">
27 27 <span class="tb-details-title">{{headerTitle}}</span>
28 28 <span class="tb-details-subtitle">{{headerSubtitle}}</span>
29 29 <span style="width: 100%;" ng-transclude="headerPane"></span>
30 30 </div>
31   - <span flex></span>
32 31 <div ng-transclude="detailsButtons"></div>
33 32 <md-button class="md-icon-button" ng-click="closeDetails()">
34 33 <md-icon aria-label="close" class="material-icons">close</md-icon>
... ...
  1 +/*
  2 + * Copyright © 2016 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +export default angular.module('thingsboard.filters.keyboardShortcut', [])
  17 + .filter('keyboardShortcut', KeyboardShortcut)
  18 + .name;
  19 +
  20 +/*@ngInject*/
  21 +function KeyboardShortcut($window) {
  22 + return function(str) {
  23 + if (!str) return;
  24 + var keys = str.split('-');
  25 + var isOSX = /Mac OS X/.test($window.navigator.userAgent);
  26 +
  27 + var seperator = (!isOSX || keys.length > 2) ? '+' : '';
  28 +
  29 + var abbreviations = {
  30 + M: isOSX ? '⌘' : 'Ctrl',
  31 + A: isOSX ? 'Option' : 'Alt',
  32 + S: 'Shift'
  33 + };
  34 +
  35 + return keys.map(function(key, index) {
  36 + var last = index == keys.length - 1;
  37 + return last ? key : abbreviations[key];
  38 + }).join(seperator);
  39 + };
  40 +}
... ...
  1 +/*
  2 + * Copyright © 2016 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +export default angular.module('thingsboard.directives.mousepointMenu', [])
  18 + .directive('tbMousepointMenu', MousepointMenu)
  19 + .name;
  20 +
  21 +/*@ngInject*/
  22 +function MousepointMenu() {
  23 +
  24 + var linker = function ($scope, $element, $attrs, RightClickContextMenu) {
  25 +
  26 + $scope.$mdOpenMousepointMenu = function($event){
  27 + RightClickContextMenu.offsets = function(){
  28 + var offset = $element.offset();
  29 + var x = $event.pageX - offset.left;
  30 + var y = $event.pageY - offset.top;
  31 +
  32 + var offsets = {
  33 + left: x,
  34 + top: y
  35 + }
  36 + return offsets;
  37 + }
  38 + RightClickContextMenu.open($event);
  39 + };
  40 +
  41 + $scope.$mdCloseMousepointMenu = function() {
  42 + RightClickContextMenu.close();
  43 + }
  44 + }
  45 +
  46 + return {
  47 + restrict: "A",
  48 + link: linker,
  49 + require: 'mdMenu'
  50 + };
  51 +}
... ...
... ... @@ -20,7 +20,7 @@ const PREFIX_REGEXP = /^((?:x|data)[\:\-_])/i;
20 20 var tbEventDirectives = {};
21 21
22 22 angular.forEach(
23   - 'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave keydown keyup keypress submit focus blur copy cut paste'.split(' '),
  23 + 'click dblclick mousedown mouseup mouseover mouseout mousemove mouseenter mouseleave contextmenu keydown keyup keypress submit focus blur copy cut paste'.split(' '),
24 24 function(eventName) {
25 25 var directiveName = directiveNormalize('tb-' + eventName);
26 26 tbEventDirectives[directiveName] = ['$parse', '$rootScope', function($parse) {
... ...
... ... @@ -25,7 +25,7 @@
25 25 <input name="title" ng-model="title">
26 26 </md-input-container>
27 27 <span translate>widget-config.general-settings</span>
28   - <div layout="row" layout-align="start center">
  28 + <div layout='column' layout-align="center" layout-gt-sm='row' layout-align-gt-sm="start center">
29 29 <div layout="row" layout-padding>
30 30 <md-checkbox flex aria-label="{{ 'widget-config.display-title' | translate }}"
31 31 ng-model="showTitle">{{ 'widget-config.display-title' | translate }}
... ... @@ -80,7 +80,7 @@
80 80 <div flex layout="row" layout-align="start center"
81 81 style="padding: 0 0 0 10px; margin: 5px;">
82 82 <span translate style="min-width: 110px;">widget-config.datasource-type</span>
83   - <span translate flex
  83 + <span hide show-gt-sm translate flex
84 84 style="padding-left: 10px;">widget-config.datasource-parameters</span>
85 85 <span style="min-width: 40px;"></span>
86 86 </div>
... ...
... ... @@ -55,12 +55,26 @@ function WidgetsBundleSelect($compile, $templateCache, widgetService, types) {
55 55 if (widgetsBundles.length > 0) {
56 56 scope.widgetsBundle = widgetsBundles[0];
57 57 }
  58 + } else if (angular.isDefined(scope.selectBundleAlias)) {
  59 + selectWidgetsBundleByAlias(scope.selectBundleAlias);
58 60 }
59 61 },
60 62 function fail() {
61 63 }
62 64 );
63 65
  66 + function selectWidgetsBundleByAlias(alias) {
  67 + if (scope.widgetsBundles && alias) {
  68 + for (var w in scope.widgetsBundles) {
  69 + var widgetsBundle = scope.widgetsBundles[w];
  70 + if (widgetsBundle.alias === alias) {
  71 + scope.widgetsBundle = widgetsBundle;
  72 + break;
  73 + }
  74 + }
  75 + }
  76 + }
  77 +
64 78 scope.isSystem = function(item) {
65 79 return item && item.tenantId.id === types.id.nullUid;
66 80 }
... ... @@ -79,6 +93,12 @@ function WidgetsBundleSelect($compile, $templateCache, widgetService, types) {
79 93 scope.updateView();
80 94 });
81 95
  96 + scope.$watch('selectBundleAlias', function (newVal, prevVal) {
  97 + if (newVal !== prevVal) {
  98 + selectWidgetsBundleByAlias(scope.selectBundleAlias);
  99 + }
  100 + });
  101 +
82 102 $compile(element.contents())(scope);
83 103 }
84 104
... ... @@ -90,7 +110,8 @@ function WidgetsBundleSelect($compile, $templateCache, widgetService, types) {
90 110 bundlesScope: '@',
91 111 theForm: '=?',
92 112 tbRequired: '=?',
93   - selectFirstBundle: '='
  113 + selectFirstBundle: '=',
  114 + selectBundleAlias: '=?'
94 115 }
95 116 };
96 117 }
\ No newline at end of file
... ...
... ... @@ -35,10 +35,12 @@ tb-widgets-bundle-select {
35 35
36 36 tb-widgets-bundle-select, .tb-widgets-bundle-select {
37 37 .md-text {
  38 + display: block;
38 39 width: 100%;
39 40 }
40 41 .tb-bundle-item {
41   - display: block;
  42 + display: inline-block;
  43 + width: 100%;
42 44 span {
43 45 display: inline-block;
44 46 vertical-align: middle;
... ...
... ... @@ -28,6 +28,10 @@ export default function DashboardSettingsController($scope, $mdDialog, gridSetti
28 28
29 29 vm.gridSettings = gridSettings || {};
30 30
  31 + if (angular.isUndefined(vm.gridSettings.showTitle)) {
  32 + vm.gridSettings.showTitle = true;
  33 + }
  34 +
31 35 vm.gridSettings.backgroundColor = vm.gridSettings.backgroundColor || 'rgba(0,0,0,0)';
32 36 vm.gridSettings.columns = vm.gridSettings.columns || 24;
33 37 vm.gridSettings.margins = vm.gridSettings.margins || [10, 10];
... ...
... ... @@ -31,6 +31,11 @@
31 31 <md-dialog-content>
32 32 <div class="md-dialog-content">
33 33 <fieldset ng-disabled="loading">
  34 + <div layout="row" layout-padding>
  35 + <md-checkbox flex aria-label="{{ 'dashboard.display-title' | translate }}"
  36 + ng-model="vm.gridSettings.showTitle">{{ 'dashboard.display-title' | translate }}
  37 + </md-checkbox>
  38 + </div>
34 39 <md-input-container class="md-block">
35 40 <label translate>dashboard.columns-count</label>
36 41 <input required type="number" step="any" name="columns" ng-model="vm.gridSettings.columns" min="10"
... ...
... ... @@ -23,7 +23,7 @@ import addWidgetTemplate from './add-widget.tpl.html';
23 23
24 24 /*@ngInject*/
25 25 export default function DashboardController(types, widgetService, userService,
26   - dashboardService, $window, $rootScope,
  26 + dashboardService, itembuffer, hotkeys, $window, $rootScope,
27 27 $scope, $state, $stateParams, $mdDialog, $timeout, $document, $q, $translate, $filter) {
28 28
29 29 var user = userService.getCurrentUser();
... ... @@ -48,7 +48,10 @@ export default function DashboardController(types, widgetService, userService,
48 48 vm.addWidgetFromType = addWidgetFromType;
49 49 vm.dashboardInited = dashboardInited;
50 50 vm.dashboardInitFailed = dashboardInitFailed;
  51 + vm.widgetMouseDown = widgetMouseDown;
51 52 vm.widgetClicked = widgetClicked;
  53 + vm.prepareDashboardContextMenu = prepareDashboardContextMenu;
  54 + vm.prepareWidgetContextMenu = prepareWidgetContextMenu;
52 55 vm.editWidget = editWidget;
53 56 vm.isTenantAdmin = isTenantAdmin;
54 57 vm.loadDashboard = loadDashboard;
... ... @@ -63,6 +66,7 @@ export default function DashboardController(types, widgetService, userService,
63 66 vm.toggleDashboardEditMode = toggleDashboardEditMode;
64 67 vm.onRevertWidgetEdit = onRevertWidgetEdit;
65 68 vm.helpLinkIdForWidgetType = helpLinkIdForWidgetType;
  69 + vm.displayTitle = displayTitle;
66 70
67 71 vm.widgetsBundle;
68 72
... ... @@ -194,6 +198,7 @@ export default function DashboardController(types, widgetService, userService,
194 198
195 199 function dashboardInited(dashboard) {
196 200 vm.dashboardContainer = dashboard;
  201 + initHotKeys();
197 202 }
198 203
199 204 function isTenantAdmin() {
... ... @@ -289,18 +294,194 @@ export default function DashboardController(types, widgetService, userService,
289 294 var delayOffset = transition ? 350 : 0;
290 295 var delay = transition ? 400 : 300;
291 296 $timeout(function () {
292   - vm.dashboardContainer.highlightWidget(vm.editingWidgetIndex, delay);
  297 + vm.dashboardContainer.highlightWidget(widget, delay);
293 298 }, delayOffset, false);
294 299 }
295 300 }
296 301 }
297 302
  303 + function widgetMouseDown($event, widget) {
  304 + if (vm.isEdit && !vm.isEditingWidget) {
  305 + vm.dashboardContainer.selectWidget(widget, 0);
  306 + }
  307 + }
  308 +
298 309 function widgetClicked($event, widget) {
299 310 if (vm.isEditingWidget) {
300 311 editWidget($event, widget);
301 312 }
302 313 }
303 314
  315 + function isHotKeyAllowed(event) {
  316 + var target = event.target || event.srcElement;
  317 + var scope = angular.element(target).scope();
  318 + return scope && scope.$parent !== $rootScope;
  319 + }
  320 +
  321 + function initHotKeys() {
  322 + $translate(['action.copy', 'action.paste', 'action.delete']).then(function (translations) {
  323 + hotkeys.bindTo($scope)
  324 + .add({
  325 + combo: 'ctrl+c',
  326 + description: translations['action.copy'],
  327 + callback: function (event) {
  328 + if (isHotKeyAllowed(event) &&
  329 + vm.isEdit && !vm.isEditingWidget && !vm.widgetEditMode) {
  330 + var widget = vm.dashboardContainer.getSelectedWidget();
  331 + if (widget) {
  332 + event.preventDefault();
  333 + copyWidget(event, widget);
  334 + }
  335 + }
  336 + }
  337 + })
  338 + .add({
  339 + combo: 'ctrl+v',
  340 + description: translations['action.paste'],
  341 + callback: function (event) {
  342 + if (isHotKeyAllowed(event) &&
  343 + vm.isEdit && !vm.isEditingWidget && !vm.widgetEditMode) {
  344 + if (itembuffer.hasWidget()) {
  345 + event.preventDefault();
  346 + pasteWidget(event);
  347 + }
  348 + }
  349 + }
  350 + })
  351 + .add({
  352 + combo: 'ctrl+x',
  353 + description: translations['action.delete'],
  354 + callback: function (event) {
  355 + if (isHotKeyAllowed(event) &&
  356 + vm.isEdit && !vm.isEditingWidget && !vm.widgetEditMode) {
  357 + var widget = vm.dashboardContainer.getSelectedWidget();
  358 + if (widget) {
  359 + event.preventDefault();
  360 + removeWidget(event, widget);
  361 + }
  362 + }
  363 + }
  364 + });
  365 + });
  366 + }
  367 +
  368 + function prepareDashboardContextMenu() {
  369 + var dashboardContextActions = [];
  370 + if (vm.isEdit && !vm.isEditingWidget && !vm.widgetEditMode) {
  371 + dashboardContextActions.push(
  372 + {
  373 + action: openDashboardSettings,
  374 + enabled: true,
  375 + value: "dashboard.settings",
  376 + icon: "settings"
  377 + }
  378 + );
  379 + dashboardContextActions.push(
  380 + {
  381 + action: openDeviceAliases,
  382 + enabled: true,
  383 + value: "device.aliases",
  384 + icon: "devices_other"
  385 + }
  386 + );
  387 + dashboardContextActions.push(
  388 + {
  389 + action: pasteWidget,
  390 + enabled: itembuffer.hasWidget(),
  391 + value: "action.paste",
  392 + icon: "content_paste",
  393 + shortcut: "M-V"
  394 + }
  395 + );
  396 + }
  397 + return dashboardContextActions;
  398 + }
  399 +
  400 + function pasteWidget($event) {
  401 + var pos = vm.dashboardContainer.getEventGridPosition($event);
  402 + itembuffer.pasteWidget(vm.dashboard, pos);
  403 + }
  404 +
  405 + function prepareWidgetContextMenu() {
  406 + var widgetContextActions = [];
  407 + if (vm.isEdit && !vm.isEditingWidget) {
  408 + widgetContextActions.push(
  409 + {
  410 + action: editWidget,
  411 + enabled: true,
  412 + value: "action.edit",
  413 + icon: "edit"
  414 + }
  415 + );
  416 + if (!vm.widgetEditMode) {
  417 + widgetContextActions.push(
  418 + {
  419 + action: copyWidget,
  420 + enabled: true,
  421 + value: "action.copy",
  422 + icon: "content_copy",
  423 + shortcut: "M-C"
  424 + }
  425 + );
  426 + widgetContextActions.push(
  427 + {
  428 + action: removeWidget,
  429 + enabled: true,
  430 + value: "action.delete",
  431 + icon: "clear",
  432 + shortcut: "M-X"
  433 + }
  434 + );
  435 + }
  436 + }
  437 + return widgetContextActions;
  438 + }
  439 +
  440 + function copyWidget($event, widget) {
  441 + var aliasesInfo = {
  442 + datasourceAliases: {},
  443 + targetDeviceAliases: {}
  444 + };
  445 + var originalColumns = 24;
  446 + if (vm.dashboard.configuration.gridSettings &&
  447 + vm.dashboard.configuration.gridSettings.columns) {
  448 + originalColumns = vm.dashboard.configuration.gridSettings.columns;
  449 + }
  450 + if (widget.config && vm.dashboard.configuration
  451 + && vm.dashboard.configuration.deviceAliases) {
  452 + var deviceAlias;
  453 + if (widget.config.datasources) {
  454 + for (var i=0;i<widget.config.datasources.length;i++) {
  455 + var datasource = widget.config.datasources[i];
  456 + if (datasource.type === types.datasourceType.device && datasource.deviceAliasId) {
  457 + deviceAlias = vm.dashboard.configuration.deviceAliases[datasource.deviceAliasId];
  458 + if (deviceAlias) {
  459 + aliasesInfo.datasourceAliases[i] = {
  460 + aliasName: deviceAlias.alias,
  461 + deviceId: deviceAlias.deviceId
  462 + }
  463 + }
  464 + }
  465 + }
  466 + }
  467 + if (widget.config.targetDeviceAliasIds) {
  468 + for (i=0;i<widget.config.targetDeviceAliasIds.length;i++) {
  469 + var targetDeviceAliasId = widget.config.targetDeviceAliasIds[i];
  470 + if (targetDeviceAliasId) {
  471 + deviceAlias = vm.dashboard.configuration.deviceAliases[targetDeviceAliasId];
  472 + if (deviceAlias) {
  473 + aliasesInfo.targetDeviceAliases[i] = {
  474 + aliasName: deviceAlias.alias,
  475 + deviceId: deviceAlias.deviceId
  476 + }
  477 + }
  478 + }
  479 + }
  480 + }
  481 + }
  482 + itembuffer.copyWidget(widget, aliasesInfo, originalColumns);
  483 + }
  484 +
304 485 function helpLinkIdForWidgetType() {
305 486 var link = 'widgetsConfig';
306 487 if (vm.editingWidget && vm.editingWidget.type) {
... ... @@ -322,6 +503,15 @@ export default function DashboardController(types, widgetService, userService,
322 503 return link;
323 504 }
324 505
  506 + function displayTitle() {
  507 + if (vm.dashboard && vm.dashboard.configuration.gridSettings &&
  508 + angular.isDefined(vm.dashboard.configuration.gridSettings.showTitle)) {
  509 + return vm.dashboard.configuration.gridSettings.showTitle;
  510 + } else {
  511 + return true;
  512 + }
  513 + }
  514 +
325 515 function onRevertWidgetEdit(widgetForm) {
326 516 if (widgetForm.$dirty) {
327 517 widgetForm.$setPristine();
... ... @@ -331,7 +521,9 @@ export default function DashboardController(types, widgetService, userService,
331 521
332 522 function saveWidget(widgetForm) {
333 523 widgetForm.$setPristine();
334   - vm.widgets[vm.editingWidgetIndex] = angular.copy(vm.editingWidget);
  524 + var widget = angular.copy(vm.editingWidget);
  525 + vm.widgets[vm.editingWidgetIndex] = widget;
  526 + vm.dashboardContainer.highlightWidget(widget, 0);
335 527 }
336 528
337 529 function onEditWidgetClosed() {
... ... @@ -421,8 +613,8 @@ export default function DashboardController(types, widgetService, userService,
421 613 });
422 614 }
423 615
424   - function toggleDashboardEditMode() {
425   - vm.isEdit = !vm.isEdit;
  616 + function setEditMode(isEdit, revert) {
  617 + vm.isEdit = isEdit;
426 618 if (vm.isEdit) {
427 619 if (vm.widgetEditMode) {
428 620 vm.prevWidgets = angular.copy(vm.widgets);
... ... @@ -433,14 +625,23 @@ export default function DashboardController(types, widgetService, userService,
433 625 if (vm.widgetEditMode) {
434 626 vm.widgets = vm.prevWidgets;
435 627 } else {
436   - vm.dashboard = vm.prevDashboard;
437   - vm.widgets = vm.dashboard.configuration.widgets;
  628 + if (vm.dashboardContainer) {
  629 + vm.dashboardContainer.resetHighlight();
  630 + }
  631 + if (revert) {
  632 + vm.dashboard = vm.prevDashboard;
  633 + vm.widgets = vm.dashboard.configuration.widgets;
  634 + }
438 635 }
439 636 }
440 637 }
441 638
  639 + function toggleDashboardEditMode() {
  640 + setEditMode(!vm.isEdit, true);
  641 + }
  642 +
442 643 function saveDashboard() {
443   - vm.isEdit = false;
  644 + setEditMode(false, false);
444 645 notifyDashboardUpdated();
445 646 }
446 647
... ...
... ... @@ -16,7 +16,7 @@
16 16
17 17 -->
18 18 <md-content flex tb-expand-fullscreen="vm.widgetEditMode" hide-expand-button="vm.widgetEditMode">
19   - <section ng-show="!vm.isAddingWidget && !loading && !vm.widgetEditMode" layout="row" layout-wrap
  19 + <!--section ng-show="!vm.isAddingWidget && !loading && !vm.widgetEditMode" layout="row" layout-wrap
20 20 class="tb-header-buttons tb-top-header-buttons md-fab" ng-style="{'right': '50px'}">
21 21 <md-button ng-if="vm.isTenantAdmin()" ng-show="vm.isEdit" ng-disabled="loading"
22 22 class="tb-btn-header md-accent md-hue-2 md-fab md-fab-bottom-right"
... ... @@ -37,7 +37,7 @@
37 37 <ng-md-icon icon="{{vm.isEdit ? 'close' : 'edit'}}"
38 38 options='{"easing": "circ-in-out", "duration": 375, "rotation": "none"}'></ng-md-icon>
39 39 </md-button>
40   - </section>
  40 + </section-->
41 41 <section ng-show="!loading && vm.noData()" layout-align="center center"
42 42 ng-class="{'tb-padded' : !vm.widgetEditMode}"
43 43 style="text-transform: uppercase; display: flex; z-index: 1;"
... ... @@ -51,7 +51,7 @@
51 51 </md-button>
52 52 </section>
53 53 <section ng-if="!vm.widgetEditMode" class="tb-dashboard-title" layout="row" layout-align="center center">
54   - <h3 ng-show="!vm.isEdit">{{ vm.dashboard.title }}</h3>
  54 + <h3 ng-show="!vm.isEdit && vm.displayTitle()">{{ vm.dashboard.title }}</h3>
55 55 <md-input-container ng-show="vm.isEdit" class="md-block" style="height: 30px;">
56 56 <label translate>dashboard.title</label>
57 57 <input class="tb-dashboard-title" required name="title" ng-model="vm.dashboard.title">
... ... @@ -64,7 +64,7 @@
64 64 </md-button>
65 65 </section>
66 66 <div class="tb-absolute-fill"
67   - ng-class="{ 'tb-padded' : !vm.widgetEditMode, 'tb-shrinked' : vm.isEditingWidget }">
  67 + ng-class="{ 'tb-padded' : !vm.widgetEditMode && (vm.isEdit || vm.displayTitle()), 'tb-shrinked' : vm.isEditingWidget }">
68 68 <tb-dashboard
69 69 dashboard-style="{'background-color': vm.dashboard.configuration.gridSettings.backgroundColor,
70 70 'background-image': 'url('+vm.dashboard.configuration.gridSettings.backgroundImageUrl+')',
... ... @@ -82,7 +82,11 @@
82 82 is-edit-action-enabled="vm.isEdit || vm.widgetEditMode"
83 83 is-remove-action-enabled="vm.isEdit && !vm.widgetEditMode"
84 84 on-edit-widget="vm.editWidget(event, widget)"
  85 + on-widget-mouse-down="vm.widgetMouseDown(event, widget)"
85 86 on-widget-clicked="vm.widgetClicked(event, widget)"
  87 + on-widget-context-menu="vm.widgetContextMenu(event, widget)"
  88 + prepare-dashboard-context-menu="vm.prepareDashboardContextMenu()"
  89 + prepare-widget-context-menu="vm.prepareWidgetContextMenu(widget)"
86 90 on-remove-widget="vm.removeWidget(event, widget)"
87 91 load-widgets="vm.loadDashboard()"
88 92 on-init="vm.dashboardInited(dashboard)"
... ... @@ -176,8 +180,8 @@
176 180 </div>
177 181 </tb-details-sidenav>
178 182 <!-- </section> -->
179   - <section layout="row" layout-wrap class="tb-footer-buttons md-fab ">
180   - <md-button ng-disabled="loading" ng-if="!vm.isAddingWidget && vm.isEdit && !vm.widgetEditMode"
  183 + <section layout="row" layout-wrap class="tb-footer-buttons md-fab">
  184 + <md-button ng-disabled="loading" ng-show="!vm.isAddingWidget && vm.isEdit && !vm.widgetEditMode"
181 185 class="tb-btn-footer md-accent md-hue-2 md-fab" ng-click="vm.addWidget($event)"
182 186 aria-label="{{ 'dashboard.add-widget' | translate }}">
183 187 <md-tooltip md-direction="top">
... ... @@ -185,5 +189,25 @@
185 189 </md-tooltip>
186 190 <ng-md-icon icon="add"></ng-md-icon>
187 191 </md-button>
  192 + <md-button ng-if="vm.isTenantAdmin()" ng-show="vm.isEdit && !vm.isAddingWidget && !loading && !vm.widgetEditMode" ng-disabled="loading"
  193 + class="tb-btn-footer md-accent md-hue-2 md-fab"
  194 + aria-label="{{ 'action.apply' | translate }}"
  195 + ng-click="vm.saveDashboard()">
  196 + <md-tooltip md-direction="top">
  197 + {{ 'action.apply-changes' | translate }}
  198 + </md-tooltip>
  199 + <ng-md-icon icon="done"></ng-md-icon>
  200 + </md-button>
  201 + <md-button ng-show="!vm.isAddingWidget && !loading && !vm.widgetEditMode"
  202 + ng-if="vm.isTenantAdmin()" ng-disabled="loading"
  203 + class="tb-btn-footer md-accent md-hue-2 md-fab"
  204 + aria-label="{{ 'action.edit-mode' | translate }}"
  205 + ng-click="vm.toggleDashboardEditMode()">
  206 + <md-tooltip md-direction="top">
  207 + {{ (vm.isEdit ? 'action.decline-changes' : 'action.enter-edit-mode') | translate }}
  208 + </md-tooltip>
  209 + <ng-md-icon icon="{{vm.isEdit ? 'close' : 'edit'}}"
  210 + options='{"easing": "circ-in-out", "duration": 375, "rotation": "none"}'></ng-md-icon>
  211 + </md-button>
188 212 </section>
189 213 </md-content>
... ...
... ... @@ -29,6 +29,7 @@ import thingsboardDashboard from '../components/dashboard.directive';
29 29 import thingsboardExpandFullscreen from '../components/expand-fullscreen.directive';
30 30 import thingsboardWidgetsBundleSelect from '../components/widgets-bundle-select.directive';
31 31 import thingsboardTypes from '../common/types.constant';
  32 +import thingsboardItemBuffer from '../services/item-buffer.service';
32 33
33 34 import DashboardRoutes from './dashboard.routes';
34 35 import DashboardsController from './dashboards.controller';
... ... @@ -45,6 +46,7 @@ export default angular.module('thingsboard.dashboard', [
45 46 uiRouter,
46 47 gridster.name,
47 48 thingsboardTypes,
  49 + thingsboardItemBuffer,
48 50 thingsboardGrid,
49 51 thingsboardApiWidget,
50 52 thingsboardApiUser,
... ...
... ... @@ -14,7 +14,7 @@
14 14 * limitations under the License.
15 15 */
16 16 /*@ngInject*/
17   -export default function AddWidgetToDashboardDialogController($scope, $mdDialog, $state, dashboardService, deviceId, deviceName, widget) {
  17 +export default function AddWidgetToDashboardDialogController($scope, $mdDialog, $state, itembuffer, dashboardService, deviceId, deviceName, widget) {
18 18
19 19 var vm = this;
20 20
... ... @@ -34,62 +34,20 @@ export default function AddWidgetToDashboardDialogController($scope, $mdDialog,
34 34 function add() {
35 35 $scope.theForm.$setPristine();
36 36 var theDashboard;
37   - var deviceAliases;
38   - widget.col = 0;
39   - widget.sizeX /= 2;
40   - widget.sizeY /= 2;
41 37 if (vm.addToDashboardType === 0) {
42 38 theDashboard = vm.dashboard;
43   - if (!theDashboard.configuration) {
44   - theDashboard.configuration = {};
45   - }
46   - deviceAliases = theDashboard.configuration.deviceAliases;
47   - if (!deviceAliases) {
48   - deviceAliases = {};
49   - theDashboard.configuration.deviceAliases = deviceAliases;
50   - }
51   - var newAliasId;
52   - for (var aliasId in deviceAliases) {
53   - if (deviceAliases[aliasId].deviceId === deviceId) {
54   - newAliasId = aliasId;
55   - break;
56   - }
57   - }
58   - if (!newAliasId) {
59   - var newAliasName = createDeviceAliasName(deviceAliases, deviceName);
60   - newAliasId = 0;
61   - for (aliasId in deviceAliases) {
62   - newAliasId = Math.max(newAliasId, aliasId);
63   - }
64   - newAliasId++;
65   - deviceAliases[newAliasId] = {alias: newAliasName, deviceId: deviceId};
66   - }
67   - widget.config.datasources[0].deviceAliasId = newAliasId;
68   -
69   - if (!theDashboard.configuration.widgets) {
70   - theDashboard.configuration.widgets = [];
71   - }
72   -
73   - var row = 0;
74   - for (var w in theDashboard.configuration.widgets) {
75   - var existingWidget = theDashboard.configuration.widgets[w];
76   - var wRow = existingWidget.row ? existingWidget.row : 0;
77   - var wSizeY = existingWidget.sizeY ? existingWidget.sizeY : 1;
78   - var bottom = wRow + wSizeY;
79   - row = Math.max(row, bottom);
80   - }
81   - widget.row = row;
82   - theDashboard.configuration.widgets.push(widget);
83 39 } else {
84 40 theDashboard = vm.newDashboard;
85   - deviceAliases = {};
86   - deviceAliases['1'] = {alias: deviceName, deviceId: deviceId};
87   - theDashboard.configuration = {};
88   - theDashboard.configuration.widgets = [];
89   - widget.row = 0;
90   - theDashboard.configuration.widgets.push(widget);
91   - theDashboard.configuration.deviceAliases = deviceAliases;
92 41 }
  42 + var aliasesInfo = {
  43 + datasourceAliases: {},
  44 + targetDeviceAliases: {}
  45 + };
  46 + aliasesInfo.datasourceAliases[0] = {
  47 + aliasName: deviceName,
  48 + deviceId: deviceId
  49 + };
  50 + theDashboard = itembuffer.addWidgetToDashboard(theDashboard, widget, aliasesInfo, 48, -1, -1);
93 51 dashboardService.saveDashboard(theDashboard).then(
94 52 function success(dashboard) {
95 53 $mdDialog.hide();
... ... @@ -98,25 +56,6 @@ export default function AddWidgetToDashboardDialogController($scope, $mdDialog,
98 56 }
99 57 }
100 58 );
101   -
102   - }
103   -
104   - function createDeviceAliasName(deviceAliases, alias) {
105   - var c = 0;
106   - var newAlias = angular.copy(alias);
107   - var unique = false;
108   - while (!unique) {
109   - unique = true;
110   - for (var devAliasId in deviceAliases) {
111   - var devAlias = deviceAliases[devAliasId];
112   - if (newAlias === devAlias.alias) {
113   - c++;
114   - newAlias = alias + c;
115   - unique = false;
116   - }
117   - }
118   - }
119   - return newAlias;
120 59 }
121 60
122 61 }
... ...
... ... @@ -239,6 +239,8 @@ export default function AttributeTableDirective($compile, $templateCache, $rootS
239 239 index: 0
240 240 }
241 241 scope.widgetsBundle = null;
  242 + scope.firstBundle = true;
  243 + scope.selectedWidgetsBundleAlias = types.systemBundleAlias.cards;
242 244
243 245 scope.deviceAliases = {};
244 246 scope.deviceAliases['1'] = {alias: scope.deviceName, deviceId: scope.deviceId};
... ... @@ -326,13 +328,6 @@ export default function AttributeTableDirective($compile, $templateCache, $rootS
326 328 }
327 329 }
328 330 });
329   -
330   - widgetService.getWidgetsBundleByAlias(types.systemBundleAlias.cards).then(
331   - function success(widgetsBundle) {
332   - scope.firstBundle = true;
333   - scope.widgetsBundle = widgetsBundle;
334   - }
335   - );
336 331 }
337 332
338 333 scope.exitWidgetMode = function() {
... ... @@ -344,6 +339,7 @@ export default function AttributeTableDirective($compile, $templateCache, $rootS
344 339 scope.widgetsIndexWatch();
345 340 scope.widgetsIndexWatch = null;
346 341 }
  342 + scope.selectedWidgetsBundleAlias = null;
347 343 scope.mode = 'default';
348 344 scope.getDeviceAttributes(true);
349 345 }
... ...
... ... @@ -105,7 +105,8 @@
105 105 <tb-widgets-bundle-select flex-offset="5"
106 106 flex
107 107 ng-model="widgetsBundle"
108   - select-first-bundle="false">
  108 + select-first-bundle="false"
  109 + select-bundle-alias="selectedWidgetsBundleAlias">
109 110 </tb-widgets-bundle-select>
110 111 </div>
111 112 <md-button ng-show="widgetsList.length > 0" class="md-accent md-hue-2 md-raised" ng-click="addWidgetToDashboard($event)">
... ...
... ... @@ -15,7 +15,7 @@
15 15 limitations under the License.
16 16
17 17 -->
18   -<div class="tb-breadcrumb">
  18 +<div flex class="tb-breadcrumb" layout="row">
19 19 <h1 flex hide-gt-sm>{{ steps[steps.length-1].ncyBreadcrumbLabel | breadcrumbLabel }}</h1>
20 20 <span hide-xs hide-sm ng-repeat="step in steps" ng-switch="$last || !!step.abstract">
21 21 <a ng-switch-when="false" href="{{step.ncyBreadcrumbLink}}">
... ...
... ... @@ -29,6 +29,11 @@
29 29 .tb-breadcrumb {
30 30 font-size: 18px !important;
31 31 font-weight: 400 !important;
  32 + h1, a, span {
  33 + overflow: hidden;
  34 + text-overflow: ellipsis;
  35 + white-space: nowrap;
  36 + }
32 37 a {
33 38 border: none;
34 39 opacity: 0.75;
... ...
... ... @@ -39,7 +39,7 @@
39 39
40 40 <div flex layout="column" tabIndex="-1" role="main">
41 41 <md-toolbar class="md-whiteframe-z1 tb-primary-toolbar" ng-class="{'md-hue-1': vm.displaySearchMode()}">
42   - <div flex class="md-toolbar-tools">
  42 + <div layout="row" flex class="md-toolbar-tools">
43 43 <md-button id="main" hide-gt-sm
44 44 class="md-icon-button" ng-click="vm.openSidenav()" aria-label="{{ 'home.menu' | translate }}" ng-class="{'tb-invisible': vm.displaySearchMode()}">
45 45 <md-icon aria-label="{{ 'home.menu' | translate }}" class="material-icons">menu</md-icon>
... ... @@ -47,7 +47,7 @@
47 47 <md-button class="md-icon-button" aria-label="{{ 'action.back' | translate }}" ng-click="searchConfig.showSearch = !searchConfig.showSearch" ng-class="{'tb-invisible': !vm.displaySearchMode()}" >
48 48 <md-icon aria-label="{{ 'action.back' | translate }}" class="material-icons">arrow_back</md-icon>
49 49 </md-button>
50   - <div flex ng-show="!vm.displaySearchMode()" tb-no-animate flex class="md-toolbar-tools">
  50 + <div flex layout="row" ng-show="!vm.displaySearchMode()" tb-no-animate class="md-toolbar-tools">
51 51 <span ng-cloak ncy-breadcrumb></span>
52 52 </div>
53 53 <md-input-container ng-show="vm.displaySearchMode()" md-theme="tb-search-input" flex>
... ...
  1 +/*
  2 + * Copyright © 2016 The Thingsboard Authors
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +import angularStorage from 'angular-storage';
  18 +
  19 +export default angular.module('thingsboard.itembuffer', [angularStorage])
  20 + .factory('itembuffer', ItemBuffer)
  21 + .factory('bufferStore', function(store) {
  22 + var newStore = store.getNamespacedStore('tbBufferStore', null, null, false);
  23 + return newStore;
  24 + })
  25 + .name;
  26 +
  27 +/*@ngInject*/
  28 +function ItemBuffer(bufferStore) {
  29 +
  30 + const WIDGET_ITEM = "widget_item";
  31 +
  32 + var service = {
  33 + copyWidget: copyWidget,
  34 + hasWidget: hasWidget,
  35 + pasteWidget: pasteWidget,
  36 + addWidgetToDashboard: addWidgetToDashboard
  37 + }
  38 +
  39 + return service;
  40 +
  41 + /**
  42 + aliasesInfo {
  43 + datasourceAliases: {
  44 + datasourceIndex: {
  45 + aliasName: "...",
  46 + deviceId: "..."
  47 + }
  48 + }
  49 + targetDeviceAliases: {
  50 + targetDeviceAliasIndex: {
  51 + aliasName: "...",
  52 + deviceId: "..."
  53 + }
  54 + }
  55 + ....
  56 + }
  57 + **/
  58 +
  59 + function copyWidget(widget, aliasesInfo, originalColumns) {
  60 + var widgetItem = {
  61 + widget: widget,
  62 + aliasesInfo: aliasesInfo,
  63 + originalColumns: originalColumns
  64 + }
  65 + bufferStore.set(WIDGET_ITEM, angular.toJson(widgetItem));
  66 + }
  67 +
  68 + function hasWidget() {
  69 + return bufferStore.get(WIDGET_ITEM);
  70 + }
  71 +
  72 + function pasteWidget(targetDasgboard, position) {
  73 + var widgetItemJson = bufferStore.get(WIDGET_ITEM);
  74 + if (widgetItemJson) {
  75 + var widgetItem = angular.fromJson(widgetItemJson);
  76 + var widget = widgetItem.widget;
  77 + var aliasesInfo = widgetItem.aliasesInfo;
  78 + var originalColumns = widgetItem.originalColumns;
  79 + var targetRow = -1;
  80 + var targetColumn = -1;
  81 + if (position) {
  82 + targetRow = position.row;
  83 + targetColumn = position.column;
  84 + }
  85 + addWidgetToDashboard(targetDasgboard, widget, aliasesInfo, originalColumns, targetRow, targetColumn);
  86 + }
  87 + }
  88 +
  89 + function addWidgetToDashboard(dashboard, widget, aliasesInfo, originalColumns, row, column) {
  90 + var theDashboard;
  91 + if (dashboard) {
  92 + theDashboard = dashboard;
  93 + } else {
  94 + theDashboard = {};
  95 + }
  96 + if (!theDashboard.configuration) {
  97 + theDashboard.configuration = {};
  98 + }
  99 + if (!theDashboard.configuration.deviceAliases) {
  100 + theDashboard.configuration.deviceAliases = {};
  101 + }
  102 + updateAliases(theDashboard, widget, aliasesInfo);
  103 +
  104 + if (!theDashboard.configuration.widgets) {
  105 + theDashboard.configuration.widgets = [];
  106 + }
  107 + var targetColumns = 24;
  108 + if (theDashboard.configuration.gridSettings &&
  109 + theDashboard.configuration.gridSettings.columns) {
  110 + targetColumns = theDashboard.configuration.gridSettings.columns;
  111 + }
  112 + if (targetColumns != originalColumns) {
  113 + var ratio = targetColumns / originalColumns;
  114 + widget.sizeX *= ratio;
  115 + widget.sizeY *= ratio;
  116 + }
  117 + if (row > -1 && column > - 1) {
  118 + widget.row = row;
  119 + widget.col = column;
  120 + } else {
  121 + row = 0;
  122 + for (var w in theDashboard.configuration.widgets) {
  123 + var existingWidget = theDashboard.configuration.widgets[w];
  124 + var wRow = existingWidget.row ? existingWidget.row : 0;
  125 + var wSizeY = existingWidget.sizeY ? existingWidget.sizeY : 1;
  126 + var bottom = wRow + wSizeY;
  127 + row = Math.max(row, bottom);
  128 + }
  129 + widget.row = row;
  130 + widget.col = 0;
  131 + }
  132 + theDashboard.configuration.widgets.push(widget);
  133 + return theDashboard;
  134 + }
  135 +
  136 + function updateAliases(dashboard, widget, aliasesInfo) {
  137 + var deviceAliases = dashboard.configuration.deviceAliases;
  138 + var aliasInfo;
  139 + var newAliasId;
  140 + for (var datasourceIndex in aliasesInfo.datasourceAliases) {
  141 + aliasInfo = aliasesInfo.datasourceAliases[datasourceIndex];
  142 + newAliasId = getDeviceAliasId(deviceAliases, aliasInfo);
  143 + widget.config.datasources[datasourceIndex].deviceAliasId = newAliasId;
  144 + }
  145 + for (var targetDeviceAliasIndex in aliasesInfo.targetDeviceAliases) {
  146 + aliasInfo = aliasesInfo.targetDeviceAliases[targetDeviceAliasIndex];
  147 + newAliasId = getDeviceAliasId(deviceAliases, aliasInfo);
  148 + widget.config.targetDeviceAliasIds[targetDeviceAliasIndex] = newAliasId;
  149 + }
  150 + }
  151 +
  152 + function getDeviceAliasId(deviceAliases, aliasInfo) {
  153 + var newAliasId;
  154 + for (var aliasId in deviceAliases) {
  155 + if (deviceAliases[aliasId].deviceId === aliasInfo.deviceId) {
  156 + newAliasId = aliasId;
  157 + break;
  158 + }
  159 + }
  160 + if (!newAliasId) {
  161 + var newAliasName = createDeviceAliasName(deviceAliases, aliasInfo.aliasName);
  162 + newAliasId = 0;
  163 + for (aliasId in deviceAliases) {
  164 + newAliasId = Math.max(newAliasId, aliasId);
  165 + }
  166 + newAliasId++;
  167 + deviceAliases[newAliasId] = {alias: newAliasName, deviceId: aliasInfo.deviceId};
  168 + }
  169 + return newAliasId;
  170 + }
  171 +
  172 + function createDeviceAliasName(deviceAliases, alias) {
  173 + var c = 0;
  174 + var newAlias = angular.copy(alias);
  175 + var unique = false;
  176 + while (!unique) {
  177 + unique = true;
  178 + for (var devAliasId in deviceAliases) {
  179 + var devAlias = deviceAliases[devAliasId];
  180 + if (newAlias === devAlias.alias) {
  181 + c++;
  182 + newAlias = alias + c;
  183 + unique = false;
  184 + }
  185 + }
  186 + }
  187 + return newAlias;
  188 + }
  189 +
  190 +
  191 +}
\ No newline at end of file
... ...
... ... @@ -38,7 +38,9 @@
38 38 "create": "Create",
39 39 "drag": "Drag",
40 40 "refresh": "Refresh",
41   - "undo": "Undo"
  41 + "undo": "Undo",
  42 + "copy": "Copy",
  43 + "paste": "Paste"
42 44 },
43 45 "admin": {
44 46 "general": "General",
... ... @@ -211,7 +213,8 @@
211 213 "vertical-margin": "Vertical margin",
212 214 "vertical-margin-required": "Vertical margin value is required.",
213 215 "min-vertical-margin-message": "Only 0 is allowed as minimum vertical margin value.",
214   - "max-vertical-margin-message": "Only 50 is allowed as maximum vertical margin value."
  216 + "max-vertical-margin-message": "Only 50 is allowed as maximum vertical margin value.",
  217 + "display-title": "Display dashboard title"
215 218 },
216 219 "datakey": {
217 220 "settings": "Settings",
... ...
... ... @@ -169,6 +169,9 @@ md-menu-item {
169 169 md-menu-item {
170 170 .md-button {
171 171 display: block;
  172 + .tb-alt-text {
  173 + float: right;
  174 + }
172 175 }
173 176 }
174 177
... ...