diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 6550f58da4..e6fa1ab632 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -116,6 +116,22 @@ "dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, rowData, filter)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix('blue', 'red', amount = percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: '20px',\\n color: '#ffffff',\\n background: color.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor('blue');\\n backgroundColor.setAlpha(value/100);\\n var color = 'blue';\\n if (value > 50) {\\n color = 'white';\\n }\\n \\n return {\\n paddingLeft: '20px',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: '18px'\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true,\"displayPagination\":true,\"defaultPageSize\":10},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":false,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" } + }, + { + "alias": "entities_hierarchy", + "name": "Entities hierarchy", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n var scope = self.ctx.$scope;\n var id = self.ctx.$scope.$injector.get('utils').guid();\n scope.hierarchyId = \"hierarchy-\"+id;\n scope.ctx = self.ctx;\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.$broadcast('entities-hierarchy-data-updated', self.ctx.$scope.hierarchyId);\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'nodeSelected': {\n name: 'widget-action.node-selected',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n", + "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"EntitiesHierarchySettings\",\n \"properties\": {\n \"nodeRelationQueryFunction\": {\n \"title\": \"Node relations query function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodeHasChildrenFunction\": {\n \"title\": \"Node has children function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodeOpenedFunction\": {\n \"title\": \"Default node opened function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodeDisabledFunction\": {\n \"title\": \"Node disabled function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodeIconFunction\": {\n \"title\": \"Node icon function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodeTextFunction\": {\n \"title\": \"Node text function: f(nodeCtx)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"nodesSortFunction\": {\n \"title\": \"Nodes sort function: f(nodeCtx1, nodeCtx2)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n {\n \"key\": \"nodeRelationQueryFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodeHasChildrenFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodeOpenedFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodeDisabledFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodeIconFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodeTextFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"nodesSortFunction\",\n \"type\": \"javascript\"\n }\n ]\n}", + "dataKeySettingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {},\n \"required\": []\n },\n \"form\": []\n}", + "defaultConfig": "{\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":86400000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"4px\",\"settings\":{\"nodeRelationQueryFunction\":\"/**\\n\\n// Function should return relations query object for current node used to fetch entity children.\\n// Function can return 'default' string value. In this case default relations query will be used.\\n\\n// The following example code will construct simple relations query that will fetch relations of type 'Contains'\\n// from the current entity.\\n\\nvar entity = nodeCtx.entity;\\nvar query = {\\n parameters: {\\n rootId: entity.id.id,\\n rootType: entity.id.entityType,\\n direction: types.entitySearchDirection.from,\\n relationTypeGroup: \\\"COMMON\\\",\\n maxLevel: 1\\n },\\n filters: [{\\n relationType: \\\"Contains\\\",\\n entityTypes: []\\n }]\\n};\\nreturn query;\\n\\n**/\\n\",\"nodeHasChildrenFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node has children (whether it can be expanded).\\n\\n// The following example code will restrict entities hierarchy expansion up to third level.\\n\\nreturn nodeCtx.level <= 2;\\n\\n// The next example code will restrict entities expansion according to the value of example 'nodeHasChildren' attribute.\\n\\nvar data = nodeCtx.data;\\nif (data.hasOwnProperty('nodeHasChildren') && data['nodeHasChildren'] !== null) {\\n return data['nodeHasChildren'] === 'true';\\n} else {\\n return true;\\n}\\n \\n**/\\n \",\"nodeTextFunction\":\"/**\\n\\n// Function should return text (can be HTML code) for the current node.\\n\\n// The following example code will generate node text consisting of entity name and temperature if temperature value is present in entity attributes/timeseries.\\n\\nvar data = nodeCtx.data;\\nvar entity = nodeCtx.entity;\\nvar text = entity.name;\\nif (data.hasOwnProperty('temperature') && data['temperature'] !== null) {\\n text += \\\" \\\"+ data['temperature'] +\\\" °C\\\";\\n}\\nreturn text;\\n\\n**/\",\"nodeIconFunction\":\"/** \\n\\n// Function should return node icon info object.\\n// Resulting object should contain either 'materialIcon' or 'iconUrl' property. \\n// Where:\\n - 'materialIcon' - name of the material icon to be used from the Material Icons Library (https://material.io/tools/icons);\\n - 'iconUrl' - url of the external image to be used as node icon.\\n// Function can return 'default' string value. In this case default icons according to entity type will be used.\\n\\n// The following example code shows how to use external image for devices which name starts with 'Test' and use \\n// default icons for the rest of entities.\\n\\nvar entity = nodeCtx.entity;\\nif (entity.id.entityType === 'DEVICE' && entity.name.startsWith('Test')) {\\n return {iconUrl: 'https://avatars1.githubusercontent.com/u/14793288?v=4&s=117'};\\n} else {\\n return 'default';\\n}\\n \\n**/\",\"nodeDisabledFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node should be disabled (not selectable).\\n\\n// The following example code will disable current node according to the value of example 'nodeDisabled' attribute.\\n\\nvar data = nodeCtx.data;\\nif (data.hasOwnProperty('nodeDisabled') && data['nodeDisabled'] !== null) {\\n return data['nodeDisabled'] === 'true';\\n} else {\\n return false;\\n}\\n \\n**/\\n\",\"nodesSortFunction\":\"/**\\n\\n// This function is used to sort nodes of the same level. Function should compare two nodes and return \\n// integer value: \\n// - less than 0 - sort nodeCtx1 to an index lower than nodeCtx2\\n// - 0 - leave nodeCtx1 and nodeCtx2 unchanged with respect to each other\\n// - greater than 0 - sort nodeCtx2 to an index lower than nodeCtx1\\n\\n// The following example code will sort entities first by entity type in alphabetical order then\\n// by entity name in alphabetical order.\\n\\nvar result = nodeCtx1.entity.id.entityType.localeCompare(nodeCtx2.entity.id.entityType);\\nif (result === 0) {\\n result = nodeCtx1.entity.name.localeCompare(nodeCtx2.entity.name);\\n}\\nreturn result;\\n \\n**/\",\"nodeOpenedFunction\":\"/**\\n\\n// Function should return boolean value indicating whether current node should be opened (expanded) when it first loaded.\\n\\n// The following example code will open by default nodes up to third level.\\n\\nreturn nodeCtx.level <= 2;\\n\\n**/\\n \"},\"title\":\"Entities hierarchy\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400,\"padding\":\"5px 10px 5px 10px\"},\"useDashboardTimewindow\":false,\"showLegend\":false,\"datasources\":[{\"type\":\"function\",\"name\":\"Simulated\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Sin\",\"color\":\"#2196f3\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.472295003170325,\"funcBody\":\"return Math.round(1000*Math.sin(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Cos\",\"color\":\"#4caf50\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.8926244886945558,\"funcBody\":\"return Math.round(1000*Math.cos(time/5000));\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#f44336\",\"settings\":{\"columnWidth\":\"0px\",\"useCellStyleFunction\":false,\"cellStyleFunction\":\"\",\"useCellContentFunction\":false,\"cellContentFunction\":\"\"},\"_hash\":0.6401141393938932,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"widgetStyle\":{},\"actions\":{}}" + } } ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/maps.json b/application/src/main/data/json/system/widget_bundles/maps.json index a873c7a7e6..1f4807c7d4 100644 --- a/application/src/main/data/json/system/widget_bundles/maps.json +++ b/application/src/main/data/json/system/widget_bundles/maps.json @@ -116,6 +116,22 @@ "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"First point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue || 15.833293;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.24727730589425012,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.8437014651129422,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"type\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.7558240907832925,\"funcBody\":\"return \\\"colorpin\\\";\"}]},{\"type\":\"function\",\"name\":\"Second Point\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.19266205227372524,\"funcBody\":\"var value = prevValue || 14.450463;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#9c27b0\",\"settings\":{},\"_hash\":0.7995830793603149,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#8bc34a\",\"settings\":{},\"_hash\":0.04902495467943502,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"type\",\"color\":\"#3f51b5\",\"settings\":{},\"_hash\":0.44120841439482095,\"funcBody\":\"return \\\"thermomether\\\";\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"tmDefaultMapType\":\"roadmap\",\"fitMapBounds\":true,\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"label\":\"${entityName}\",\"showTooltip\":true,\"autocloseTooltip\":true,\"tooltipPattern\":\"
${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details
\",\"markerImageSize\":34,\"tmApiKey\":\"84d6d83e0e51e481e50454ccbe8986b\",\"color\":\"#fe7569\",\"useColorFunction\":true,\"colorFunction\":\"var type = dsData[dsIndex]['type'];\\nif (type == 'colorpin') {\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120 * 100;\\n\\t return tinycolor.mix('blue', 'red', amount = percent).toHexString();\\n\\t}\\n\\treturn 'blue';\\n}\\n\",\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var type = dsData[dsIndex]['type'];\\nif (type == 'thermomether') {\\n\\tvar res = {\\n\\t url: images[0],\\n\\t size: 40\\n\\t}\\n\\tvar temperature = dsData[dsIndex]['temperature'];\\n\\tif (typeof temperature !== undefined) {\\n\\t var percent = (temperature + 60)/120;\\n\\t var index = Math.min(3, Math.floor(4 * percent));\\n\\t res.url = images[index];\\n\\t}\\n\\treturn res;\\n}\",\"markerImages\":[\"data:image/png;base64,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\",\"data:image/png;base64,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\",\"data:image/png;base64,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\",\"data:image/png;base64,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\"],\"labelFunction\":\"var deviceType = dsData[dsIndex]['deviceType'];\\r\\nif (typeof deviceType !== undefined) {\\r\\n if (deviceType == \\\"energy meter\\\") {\\r\\n return '${entityName}, ${energy:2} kWt';\\r\\n } else if (deviceType == \\\"thermometer\\\") {\\r\\n return '${entityName}, ${temperature:2} °C';\\r\\n }\\r\\n}\",\"tooltipFunction\":\"var deviceType = dsData[dsIndex]['deviceType'];\\r\\nif (typeof deviceType !== undefined) {\\r\\n if (deviceType == \\\"energy meter\\\") {\\r\\n return '${entityName}
Energy: ${energy:2} kWt
';\\r\\n } else if (deviceType == \\\"thermometer\\\") {\\r\\n return '${entityName}
Temperature: ${temperature:2} °C
';\\r\\n }\\r\\n}\"},\"title\":\"Tencent Maps\",\"dropShadow\":true,\"enableFullscreen\":true,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{}}" } + }, + { + "alias": "test", + "name": "Trip Animation", + "descriptor": { + "type": "timeseries", + "sizeX": 8.5, + "sizeY": 6.5, + "resources": [], + "templateHtml": "", + "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", + "controllerScript": " self.onInit = function() {\n var $scope = self.ctx.$scope;\n $scope.self = self;\n }\n \n \n self.actionSources = function () {\n return {\n 'tooltipAction': {\n name: 'widget-action.tooltip-tag-action',\n multiple: false\n }\n }\n };\n", + "settingsSchema": "{\r\n \"schema\": {\r\n \"title\": \"Openstreet Map Configuration\",\r\n \"type\": \"object\",\r\n \"properties\": {\r\n \"mapProvider\": {\r\n \"title\": \"Map provider\",\r\n \"type\": \"string\",\r\n \"default\": \"OpenStreetMap.Mapnik\"\r\n },\r\n \"defaultZoomLevel\": {\r\n\t\t\t\t\t\"title\": \"Default map zoom level (1 - 20)\",\r\n\t\t\t\t\t\"type\": \"number\"\r\n\t\t\t\t},\r\n\t\t\t\"fitMapBounds\": {\r\n\t\t\t\t\"title\": \"Fit map bounds to cover all markers\",\r\n\t\t\t\t\"type\": \"boolean\",\r\n\t\t\t\t\"default\": true\r\n\t\t\t},\r\n \"latKeyName\": {\r\n \"title\": \"Latitude key name\",\r\n \"type\": \"string\",\r\n \"default\": \"latitude\"\r\n },\r\n \"lngKeyName\": {\r\n \"title\": \"Longitude key name\",\r\n \"type\": \"string\",\r\n \"default\": \"longitude\"\r\n },\r\n \"showLabel\": {\r\n \"title\": \"Show label\",\r\n \"type\": \"boolean\",\r\n \"default\": true\r\n },\r\n \"label\": {\r\n \"title\": \"Label (pattern examples: '${entityName}', '${entityName}: (Text ${keyName} units.)' )\",\r\n \"type\": \"string\",\r\n \"default\": \"${entityName}\"\r\n },\r\n \"useLabelFunction\": {\r\n \"title\": \"Use label function\",\r\n \"type\": \"boolean\",\r\n \"default\": false\r\n },\r\n \"labelFunction\": {\r\n \"title\": \"Label function: f(data, dsData, dsIndex)\",\r\n \"type\": \"string\"\r\n },\r\n \"showTooltip\": {\r\n \"title\": \"Show tooltip\",\r\n \"type\": \"boolean\",\r\n \"default\": true\r\n },\r\n \"tooltipColor\": {\r\n \"title\": \"Tooltip background color\",\r\n \"type\": \"string\",\r\n \"default\": \"#fff\"\r\n },\r\n \"tooltipFontColor\": {\r\n \"title\": \"Tooltip font color\",\r\n \"type\": \"string\",\r\n \"default\": \"#000\"\r\n },\r\n \"tooltipOpacity\": {\r\n \"title\": \"Tooltip opacity (0-1)\",\r\n \"type\": \"number\",\r\n \"default\": 1 \r\n },\r\n \"tooltipPattern\": {\r\n \"title\": \"Tooltip (for ex. 'Text ${keyName} units.' or Link text')\",\r\n \"type\": \"string\",\r\n \"default\": \"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
End Time: ${maxTime}
Start Time: ${minTime}\"\r\n },\r\n \"useTooltipFunction\": {\r\n \"title\": \"Use tooltip function\",\r\n \"type\": \"boolean\",\r\n \"default\": false\r\n },\r\n \"tooltipFunction\": {\r\n \"title\": \"Tooltip function: f(data, dsData, dsIndex)\",\r\n \"type\": \"string\"\r\n },\r\n \"color\": {\r\n \"title\": \"Stroke color\",\r\n \"type\": \"string\"\r\n },\r\n \"strokeWeight\": {\r\n \"title\": \"Stroke weight\",\r\n \"type\": \"number\",\r\n \"default\": 2\r\n },\r\n \"strokeOpacity\": {\r\n \"title\": \"Stroke opacity\",\r\n \"type\": \"number\",\r\n \"default\": 1\r\n },\r\n \"useColorFunction\": {\r\n \"title\": \"Use stroke color function\",\r\n \"type\": \"boolean\",\r\n \"default\": false\r\n },\r\n \"colorFunction\": {\r\n \"title\": \"Stroke color function: f(data, dsData, dsIndex)\",\r\n \"type\": \"string\"\r\n },\r\n \"showPoints\": {\r\n \"title\": \"Show points\",\r\n \"type\": \"boolean\",\r\n \"default\": false\r\n },\r\n \"pointColor\": {\r\n \"title\": \"Point color\",\r\n \"type\": \"string\"\r\n },\r\n \"pointSize\": {\r\n \"title\": \"Point size (px)\",\r\n \"type\": \"number\",\r\n \"default\": 10\r\n },\r\n \"defaultMarkerColor\": {\r\n \"title\": \"color for default marker\",\r\n \"type\": \"string\"\r\n },\r\n \"markerImage\": {\r\n \"title\": \"Custom marker image\",\r\n \"type\": \"string\"\r\n },\r\n \"markerImageSize\": {\r\n \"title\": \"Custom marker image size (px)\",\r\n \"type\": \"number\",\r\n \"default\": 34\r\n },\r\n \"rotationAngle\": {\r\n \"title\": \"Set additional rotation angle for marker (deg)\",\r\n \"type\": \"number\",\r\n \"default\": 180\r\n },\r\n \"useMarkerImageFunction\":{\r\n \"title\":\"Use marker image function\",\r\n \"type\":\"boolean\",\r\n \"default\":false\r\n },\r\n \"markerImageFunction\":{\r\n \"title\":\"Marker image function: f(data, images, dsData, dsIndex)\",\r\n \"type\":\"string\"\r\n },\r\n \"markerImages\":{\r\n \"title\":\"Marker images\",\r\n \"type\":\"array\",\r\n \"items\":{\r\n \"title\":\"Marker image\",\r\n \"type\":\"string\"\r\n }\r\n }\r\n },\r\n \"required\": []\r\n },\r\n \"form\": [{\r\n \"key\": \"mapProvider\",\r\n \"type\": \"rc-select\",\r\n \"multiple\": false,\r\n \"items\": [{\r\n \"value\": \"OpenStreetMap.Mapnik\",\r\n \"label\": \"OpenStreetMap.Mapnik (Default)\"\r\n }, {\r\n \"value\": \"OpenStreetMap.BlackAndWhite\",\r\n \"label\": \"OpenStreetMap.BlackAndWhite\"\r\n }, {\r\n \"value\": \"OpenStreetMap.HOT\",\r\n \"label\": \"OpenStreetMap.HOT\"\r\n }, {\r\n \"value\": \"Esri.WorldStreetMap\",\r\n \"label\": \"Esri.WorldStreetMap\"\r\n }, {\r\n \"value\": \"Esri.WorldTopoMap\",\r\n \"label\": \"Esri.WorldTopoMap\"\r\n }, {\r\n \"value\": \"CartoDB.Positron\",\r\n \"label\": \"CartoDB.Positron\"\r\n }, {\r\n \"value\": \"CartoDB.DarkMatter\",\r\n \"label\": \"CartoDB.DarkMatter\"\r\n }]\r\n },\"defaultZoomLevel\", \"fitMapBounds\", \"latKeyName\", \"lngKeyName\", \"showLabel\", \"label\", \"useLabelFunction\", {\r\n \"key\": \"labelFunction\",\r\n \"type\": \"javascript\"\r\n }, \"showTooltip\", {\r\n \"key\": \"tooltipColor\",\r\n \"type\": \"color\"\r\n }, {\r\n \"key\": \"tooltipFontColor\",\r\n \"type\": \"color\"\r\n },\"tooltipOpacity\", {\r\n \"key\": \"tooltipPattern\",\r\n \"type\": \"textarea\"\r\n }, \"useTooltipFunction\", {\r\n \"key\": \"tooltipFunction\",\r\n \"type\": \"javascript\"\r\n }, {\r\n \"key\": \"color\",\r\n \"type\": \"color\"\r\n }, \"useColorFunction\", {\r\n \"key\": \"colorFunction\",\r\n \"type\": \"javascript\"\r\n }, \"strokeWeight\", \"strokeOpacity\", \"showPoints\",{\r\n \"key\": \"pointColor\",\r\n \"type\": \"color\"\r\n }, \"pointSize\", {\r\n \"key\": \"defaultMarkerColor\",\r\n \"type\": \"color\"\r\n }, {\r\n \"key\": \"markerImage\",\r\n \"type\": \"image\"\r\n }, \"markerImageSize\", \"rotationAngle\",\"useMarkerImageFunction\",\r\n {\r\n \"key\":\"markerImageFunction\",\r\n \"type\":\"javascript\"\r\n }, {\r\n \"key\":\"markerImages\",\r\n \"items\":[\r\n {\r\n \"key\":\"markerImages[]\",\r\n \"type\":\"image\"\r\n }\r\n ]\r\n }]\r\n}", + "dataKeySettingsSchema": "{}", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"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\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"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];\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"mapProvider\":\"OpenStreetMap.Mapnik\",\"fitMapBounds\":true,\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"label\":\"${entityName}\",\"showTooltip\":true,\"tooltipColor\":\"#fff\",\"tooltipFontColor\":\"#000\",\"tooltipOpacity\":1,\"tooltipPattern\":\"${entityName}

Latitude: ${latitude:7}
Longitude: ${longitude:7}
End Time: ${maxTime}
Start Time: ${minTime}\",\"strokeWeight\":3,\"strokeOpacity\":1,\"pointSize\":10,\"markerImageSize\":34,\"rotationAngle\":180},\"title\":\"Trip Animation\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":false,\"actions\":{},\"legendConfig\":{\"position\":\"bottom\",\"showMin\":false,\"showMax\":false,\"showAvg\":false,\"showTotal\":false}}" + } } ] } \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java index 0f40dd37f5..aa1f8bc909 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorMessageProcessor.java @@ -118,17 +118,23 @@ class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { this.rpcSubscriptions = new HashMap<>(); this.toDeviceRpcPendingMap = new HashMap<>(); this.toServerRpcPendingMap = new HashMap<>(); - initAttributes(); - restoreSessions(); + if (initAttributes()) { + restoreSessions(); + } } - private void initAttributes() { + private boolean initAttributes() { Device device = systemContext.getDeviceService().findDeviceById(tenantId, deviceId); - this.deviceName = device.getName(); - this.deviceType = device.getType(); - this.defaultMetaData = new TbMsgMetaData(); - this.defaultMetaData.putValue("deviceName", deviceName); - this.defaultMetaData.putValue("deviceType", deviceType); + if (device != null) { + this.deviceName = device.getName(); + this.deviceType = device.getType(); + this.defaultMetaData = new TbMsgMetaData(); + this.defaultMetaData.putValue("deviceName", deviceName); + this.defaultMetaData.putValue("deviceType", deviceType); + return true; + } else { + return false; + } } void processRpcRequest(ActorContext context, ToDeviceRpcRequestActorMsg msg) { diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java index 368634eb06..44b6f3b6c5 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java @@ -91,17 +91,19 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor ruleNodeList = service.getRuleChainNodes(tenantId, entityId); - log.trace("[{}][{}] Starting rule chain with {} nodes", tenantId, entityId, ruleNodeList.size()); - // Creating and starting the actors; - for (RuleNode ruleNode : ruleNodeList) { - log.trace("[{}][{}] Creating rule node [{}]: {}", entityId, ruleNode.getId(), ruleNode.getName(), ruleNode); - ActorRef ruleNodeActor = createRuleNodeActor(context, ruleNode); - nodeActors.put(ruleNode.getId(), new RuleNodeCtx(tenantId, self, ruleNodeActor, ruleNode)); + if (ruleChain != null) { + ruleChainName = ruleChain.getName(); + List ruleNodeList = service.getRuleChainNodes(tenantId, entityId); + log.trace("[{}][{}] Starting rule chain with {} nodes", tenantId, entityId, ruleNodeList.size()); + // Creating and starting the actors; + for (RuleNode ruleNode : ruleNodeList) { + log.trace("[{}][{}] Creating rule node [{}]: {}", entityId, ruleNode.getId(), ruleNode.getName(), ruleNode); + ActorRef ruleNodeActor = createRuleNodeActor(context, ruleNode); + nodeActors.put(ruleNode.getId(), new RuleNodeCtx(tenantId, self, ruleNodeActor, ruleNode)); + } + initRoutes(ruleChain, ruleNodeList); + started = true; } - initRoutes(ruleChain, ruleNodeList); - started = true; } else { onUpdate(context); } @@ -110,31 +112,33 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor ruleNodeList = service.getRuleChainNodes(tenantId, entityId); - log.trace("[{}][{}] Updating rule chain with {} nodes", tenantId, entityId, ruleNodeList.size()); - for (RuleNode ruleNode : ruleNodeList) { - RuleNodeCtx existing = nodeActors.get(ruleNode.getId()); - if (existing == null) { - log.trace("[{}][{}] Creating rule node [{}]: {}", entityId, ruleNode.getId(), ruleNode.getName(), ruleNode); - ActorRef ruleNodeActor = createRuleNodeActor(context, ruleNode); - nodeActors.put(ruleNode.getId(), new RuleNodeCtx(tenantId, self, ruleNodeActor, ruleNode)); - } else { - log.trace("[{}][{}] Updating rule node [{}]: {}", entityId, ruleNode.getId(), ruleNode.getName(), ruleNode); - existing.setSelf(ruleNode); - existing.getSelfActor().tell(new ComponentLifecycleMsg(tenantId, existing.getSelf().getId(), ComponentLifecycleEvent.UPDATED), self); + if (ruleChain != null) { + ruleChainName = ruleChain.getName(); + List ruleNodeList = service.getRuleChainNodes(tenantId, entityId); + log.trace("[{}][{}] Updating rule chain with {} nodes", tenantId, entityId, ruleNodeList.size()); + for (RuleNode ruleNode : ruleNodeList) { + RuleNodeCtx existing = nodeActors.get(ruleNode.getId()); + if (existing == null) { + log.trace("[{}][{}] Creating rule node [{}]: {}", entityId, ruleNode.getId(), ruleNode.getName(), ruleNode); + ActorRef ruleNodeActor = createRuleNodeActor(context, ruleNode); + nodeActors.put(ruleNode.getId(), new RuleNodeCtx(tenantId, self, ruleNodeActor, ruleNode)); + } else { + log.trace("[{}][{}] Updating rule node [{}]: {}", entityId, ruleNode.getId(), ruleNode.getName(), ruleNode); + existing.setSelf(ruleNode); + existing.getSelfActor().tell(new ComponentLifecycleMsg(tenantId, existing.getSelf().getId(), ComponentLifecycleEvent.UPDATED), self); + } } - } - Set existingNodes = ruleNodeList.stream().map(RuleNode::getId).collect(Collectors.toSet()); - List removedRules = nodeActors.keySet().stream().filter(node -> !existingNodes.contains(node)).collect(Collectors.toList()); - removedRules.forEach(ruleNodeId -> { - log.trace("[{}][{}] Removing rule node [{}]", tenantId, entityId, ruleNodeId); - RuleNodeCtx removed = nodeActors.remove(ruleNodeId); - removed.getSelfActor().tell(new ComponentLifecycleMsg(tenantId, removed.getSelf().getId(), ComponentLifecycleEvent.DELETED), self); - }); + Set existingNodes = ruleNodeList.stream().map(RuleNode::getId).collect(Collectors.toSet()); + List removedRules = nodeActors.keySet().stream().filter(node -> !existingNodes.contains(node)).collect(Collectors.toList()); + removedRules.forEach(ruleNodeId -> { + log.trace("[{}][{}] Removing rule node [{}]", tenantId, entityId, ruleNodeId); + RuleNodeCtx removed = nodeActors.remove(ruleNodeId); + removed.getSelfActor().tell(new ComponentLifecycleMsg(tenantId, removed.getSelf().getId(), ComponentLifecycleEvent.DELETED), self); + }); - initRoutes(ruleChain, ruleNodeList); + initRoutes(ruleChain, ruleNodeList); + } } @Override diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java index fc8ff3dc0a..a6543795ac 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java @@ -55,7 +55,9 @@ public class RuleNodeActorMessageProcessor extends ComponentMsgProcessor componentClazz = Class.forName(ruleNode.getType()); - TbNode tbNode = (TbNode) (componentClazz.newInstance()); - tbNode.init(defaultCtx, new TbNodeConfiguration(ruleNode.getConfiguration())); + TbNode tbNode = null; + if (ruleNode != null) { + Class componentClazz = Class.forName(ruleNode.getType()); + tbNode = (TbNode) (componentClazz.newInstance()); + tbNode.init(defaultCtx, new TbNodeConfiguration(ruleNode.getConfiguration())); + } return tbNode; } diff --git a/application/src/main/java/org/thingsboard/server/config/AuditLogLevelProperties.java b/application/src/main/java/org/thingsboard/server/config/AuditLogLevelProperties.java index c932f2133e..34c4e3c46d 100644 --- a/application/src/main/java/org/thingsboard/server/config/AuditLogLevelProperties.java +++ b/application/src/main/java/org/thingsboard/server/config/AuditLogLevelProperties.java @@ -22,7 +22,7 @@ import java.util.HashMap; import java.util.Map; @Configuration -@ConfigurationProperties(prefix = "audit_log.logging_level") +@ConfigurationProperties(prefix = "audit-log.logging-level") public class AuditLogLevelProperties { private Map mask = new HashMap<>(); diff --git a/application/src/main/java/org/thingsboard/server/config/SchedulingConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SchedulingConfiguration.java new file mode 100644 index 0000000000..b70f781acd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/SchedulingConfiguration.java @@ -0,0 +1,46 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.annotation.SchedulingConfigurer; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.scheduling.config.ScheduledTaskRegistrar; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +@Configuration +@EnableScheduling +public class SchedulingConfiguration implements SchedulingConfigurer { + + @Override + public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { + taskRegistrar.setScheduler(taskScheduler()); + } + + @Bean(destroyMethod="shutdown") + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler threadPoolScheduler = new ThreadPoolTaskScheduler(); + threadPoolScheduler.setThreadNamePrefix("TB-Scheduling-"); + threadPoolScheduler.setPoolSize(Runtime.getRuntime().availableProcessors()); + threadPoolScheduler.setRemoveOnCancelPolicy(true); + return threadPoolScheduler; + } +} diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardMessageConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardMessageConfiguration.java index 2402a2f6a9..fc25af3a56 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardMessageConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardMessageConfiguration.java @@ -15,11 +15,28 @@ */ package org.thingsboard.server.config; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections.ExtendedProperties; +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.apache.velocity.app.VelocityEngine; +import org.apache.velocity.exception.ResourceNotFoundException; +import org.apache.velocity.runtime.RuntimeConstants; +import org.apache.velocity.runtime.resource.loader.ClasspathResourceLoader; import org.springframework.context.MessageSource; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.core.io.DefaultResourceLoader; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.util.StringUtils; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; @Configuration public class ThingsboardMessageConfiguration { @@ -32,5 +49,114 @@ public class ThingsboardMessageConfiguration { messageSource.setDefaultEncoding("UTF-8"); return messageSource; } - + + private static final String DEFAULT_RESOURCE_LOADER_PATH = "classpath:/templates/"; + + private ResourceLoader resourceLoader = new DefaultResourceLoader(); + + @Bean + public VelocityEngine velocityEngine() { + VelocityEngine velocityEngine = new VelocityEngine(); + try { + Resource resource = resourceLoader.getResource(DEFAULT_RESOURCE_LOADER_PATH); + File file = resource.getFile(); + velocityEngine.setProperty(RuntimeConstants.RESOURCE_LOADER, "file"); + velocityEngine.setProperty(RuntimeConstants.FILE_RESOURCE_LOADER_CACHE, "true"); + velocityEngine.setProperty(RuntimeConstants.FILE_RESOURCE_LOADER_PATH, file.getAbsolutePath()); + } catch (IOException e) { + initSpringResourceLoader(velocityEngine, DEFAULT_RESOURCE_LOADER_PATH); + } + velocityEngine.init(); + return velocityEngine; + } + + private void initSpringResourceLoader(VelocityEngine velocityEngine, String resourceLoaderPath) { + velocityEngine.setProperty( + RuntimeConstants.RESOURCE_LOADER, SpringResourceLoader.NAME); + velocityEngine.setProperty( + SpringResourceLoader.SPRING_RESOURCE_LOADER_CLASS, SpringResourceLoader.class.getName()); + velocityEngine.setProperty( + SpringResourceLoader.SPRING_RESOURCE_LOADER_CACHE, "true"); + velocityEngine.setApplicationAttribute( + SpringResourceLoader.SPRING_RESOURCE_LOADER, resourceLoader); + velocityEngine.setApplicationAttribute( + SpringResourceLoader.SPRING_RESOURCE_LOADER_PATH, resourceLoaderPath); + } + + @Slf4j + static class SpringResourceLoader extends org.apache.velocity.runtime.resource.loader.ResourceLoader { + + public static final String NAME = "spring"; + + public static final String SPRING_RESOURCE_LOADER_CLASS = "spring.resource.loader.class"; + + public static final String SPRING_RESOURCE_LOADER_CACHE = "spring.resource.loader.cache"; + + public static final String SPRING_RESOURCE_LOADER = "spring.resource.loader"; + + public static final String SPRING_RESOURCE_LOADER_PATH = "spring.resource.loader.path"; + + private org.springframework.core.io.ResourceLoader resourceLoader; + + private String[] resourceLoaderPaths; + + + @Override + public void init(ExtendedProperties configuration) { + this.resourceLoader = (org.springframework.core.io.ResourceLoader) + this.rsvc.getApplicationAttribute(SPRING_RESOURCE_LOADER); + String resourceLoaderPath = (String) this.rsvc.getApplicationAttribute(SPRING_RESOURCE_LOADER_PATH); + if (this.resourceLoader == null) { + throw new IllegalArgumentException( + "'resourceLoader' application attribute must be present for SpringResourceLoader"); + } + if (resourceLoaderPath == null) { + throw new IllegalArgumentException( + "'resourceLoaderPath' application attribute must be present for SpringResourceLoader"); + } + this.resourceLoaderPaths = StringUtils.commaDelimitedListToStringArray(resourceLoaderPath); + for (int i = 0; i < this.resourceLoaderPaths.length; i++) { + String path = this.resourceLoaderPaths[i]; + if (!path.endsWith("/")) { + this.resourceLoaderPaths[i] = path + "/"; + } + } + if (log.isInfoEnabled()) { + log.info("SpringResourceLoader for Velocity: using resource loader [" + this.resourceLoader + + "] and resource loader paths " + Arrays.asList(this.resourceLoaderPaths)); + } + } + + @Override + public InputStream getResourceStream(String source) throws ResourceNotFoundException { + if (log.isDebugEnabled()) { + log.debug("Looking for Velocity resource with name [" + source + "]"); + } + for (String resourceLoaderPath : this.resourceLoaderPaths) { + org.springframework.core.io.Resource resource = + this.resourceLoader.getResource(resourceLoaderPath + source); + try { + return resource.getInputStream(); + } + catch (IOException ex) { + if (log.isDebugEnabled()) { + log.debug("Could not find Velocity resource: " + resource); + } + } + } + throw new ResourceNotFoundException( + "Could not find resource [" + source + "] in Spring resource loader path"); + } + + @Override + public boolean isSourceModified(org.apache.velocity.runtime.resource.Resource resource) { + return false; + } + + @Override + public long getLastModified(org.apache.velocity.runtime.resource.Resource resource) { + return 0; + } + + } } diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index 541e4bd17d..653ad68232 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -57,7 +57,7 @@ import java.util.List; @Configuration @EnableWebSecurity @EnableGlobalMethodSecurity(prePostEnabled=true) -@Order(SecurityProperties.ACCESS_OVERRIDE_ORDER) +@Order(SecurityProperties.BASIC_AUTH_ORDER) public class ThingsboardSecurityConfiguration extends WebSecurityConfigurerAdapter { public static final String JWT_TOKEN_HEADER_PARAM = "X-Authorization"; diff --git a/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java b/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java index 711d4ce029..b8b703e4fc 100644 --- a/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java @@ -58,7 +58,7 @@ public class WebSocketConfiguration implements WebSocketConfigurer { @Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, - Map attributes) throws Exception { + Map attributes) throws Exception { SecurityUser user = null; try { user = getCurrentUser(); @@ -73,7 +73,7 @@ public class WebSocketConfiguration implements WebSocketConfigurer { @Override public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, - Exception exception) { + Exception exception) { //Do nothing } }); diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java index f8f02a9d3b..169bba8895 100644 --- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -392,4 +392,4 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements Telemetr } } -} +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java index f6f43768a0..a0cedcf14f 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.install; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.thingsboard.server.dao.cassandra.CassandraInstallCluster; import org.thingsboard.server.service.install.cql.CQLStatementsParser; @@ -30,6 +31,7 @@ public abstract class CassandraAbstractDatabaseSchemaService implements Database private static final String CASSANDRA_DIR = "cassandra"; @Autowired + @Qualifier("CassandraInstallCluster") private CassandraInstallCluster cluster; @Autowired diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java index 13c198e6e5..1bab31d9e0 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseUpgradeService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.install; import com.datastax.driver.core.KeyspaceMetadata; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.thingsboard.server.dao.cassandra.CassandraCluster; @@ -65,6 +66,7 @@ public class CassandraDatabaseUpgradeService implements DatabaseUpgradeService { private CassandraCluster cluster; @Autowired + @Qualifier("CassandraInstallCluster") private CassandraInstallCluster installCluster; @Autowired diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java index 7ade02bf19..217d62b54e 100644 --- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java +++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java @@ -18,7 +18,9 @@ package org.thingsboard.server.service.mail; import com.fasterxml.jackson.databind.JsonNode; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.apache.velocity.VelocityContext; import org.apache.velocity.app.VelocityEngine; +import org.apache.velocity.exception.VelocityException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.MessageSource; @@ -26,7 +28,6 @@ import org.springframework.core.NestedRuntimeException; import org.springframework.mail.javamail.JavaMailSenderImpl; import org.springframework.mail.javamail.MimeMessageHelper; import org.springframework.stereotype.Service; -import org.springframework.ui.velocity.VelocityEngineUtils; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; @@ -39,6 +40,8 @@ import org.thingsboard.server.dao.settings.AdminSettingsService; import javax.annotation.PostConstruct; import javax.mail.MessagingException; import javax.mail.internet.MimeMessage; +import java.io.StringWriter; +import java.io.Writer; import java.util.HashMap; import java.util.Locale; import java.util.Map; @@ -126,7 +129,7 @@ public class DefaultMailService implements MailService { Map model = new HashMap(); model.put(TARGET_EMAIL, email); - String message = VelocityEngineUtils.mergeTemplateIntoString(this.engine, + String message = mergeTemplateIntoString(this.engine, "test.vm", UTF_8, model); sendMail(testMailSender, mailFrom, email, subject, message); @@ -141,7 +144,7 @@ public class DefaultMailService implements MailService { model.put("activationLink", activationLink); model.put(TARGET_EMAIL, email); - String message = VelocityEngineUtils.mergeTemplateIntoString(this.engine, + String message = mergeTemplateIntoString(this.engine, "activation.vm", UTF_8, model); sendMail(mailSender, mailFrom, email, subject, message); @@ -156,7 +159,7 @@ public class DefaultMailService implements MailService { model.put("loginLink", loginLink); model.put(TARGET_EMAIL, email); - String message = VelocityEngineUtils.mergeTemplateIntoString(this.engine, + String message = mergeTemplateIntoString(this.engine, "account.activated.vm", UTF_8, model); sendMail(mailSender, mailFrom, email, subject, message); @@ -171,7 +174,7 @@ public class DefaultMailService implements MailService { model.put("passwordResetLink", passwordResetLink); model.put(TARGET_EMAIL, email); - String message = VelocityEngineUtils.mergeTemplateIntoString(this.engine, + String message = mergeTemplateIntoString(this.engine, "reset.password.vm", UTF_8, model); sendMail(mailSender, mailFrom, email, subject, message); @@ -186,7 +189,7 @@ public class DefaultMailService implements MailService { model.put("loginLink", loginLink); model.put(TARGET_EMAIL, email); - String message = VelocityEngineUtils.mergeTemplateIntoString(this.engine, + String message = mergeTemplateIntoString(this.engine, "password.was.reset.vm", UTF_8, model); sendMail(mailSender, mailFrom, email, subject, message); @@ -225,6 +228,22 @@ public class DefaultMailService implements MailService { } } + private static String mergeTemplateIntoString(VelocityEngine velocityEngine, String templateLocation, + String encoding, Map model) throws VelocityException { + + StringWriter result = new StringWriter(); + mergeTemplate(velocityEngine, templateLocation, encoding, model, result); + return result.toString(); + } + + private static void mergeTemplate( + VelocityEngine velocityEngine, String templateLocation, String encoding, + Map model, Writer writer) throws VelocityException { + + VelocityContext velocityContext = new VelocityContext(model); + velocityEngine.mergeTemplate(templateLocation, encoding, velocityContext, writer); + } + protected ThingsboardException handleException(Exception exception) { String message; if (exception instanceof NestedRuntimeException) { diff --git a/application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java b/application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java index c50fb62194..e4062b1b78 100644 --- a/application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java +++ b/application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java @@ -47,11 +47,4 @@ public class DefaultDeviceSessionCacheService implements DeviceSessionCacheServi log.debug("[{}] Pushing session data to cache: {}", deviceId, sessions); return sessions; } - - public static void main (String[] args){ - UUID uuid = UUID.fromString("d5db434e-9cd2-4903-8b3b-421b2d93664d"); - System.out.println(uuid.getMostSignificantBits()); - System.out.println(uuid.getLeastSignificantBits()); - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java index e6b28b33cd..86747150c9 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetryWebSocketService.java @@ -27,7 +27,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.socket.CloseStatus; -import org.springframework.web.socket.WebSocketSession; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 235646f6c2..72b8159407 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -293,11 +293,13 @@ spring.mvc.cors: # spring serve gzip compressed static resources spring.resources.chain: - gzipped: "true" + compressed: "true" strategy: content: enabled: "true" +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation: "true" + # HSQLDB DAO Configuration spring: data: @@ -331,7 +333,7 @@ spring: # password: "${SPRING_DATASOURCE_PASSWORD:postgres}" # Audit log parameters -audit_log: +audit-log: # Enable/disable audit log functionality. enabled: "${AUDIT_LOG_ENABLED:true}" # Specify partitioning size for audit log by tenant id storage. Example MINUTES, HOURS, DAYS, MONTHS @@ -340,7 +342,7 @@ audit_log: default_query_period: "${AUDIT_LOG_DEFAULT_QUERY_PERIOD:30}" # Logging levels per each entity type. # Allowed values: OFF (disable), W (log write operations), RW (log read and write operations) - logging_level: + logging-level: mask: "device": "${AUDIT_LOG_MASK_DEVICE:W}" "asset": "${AUDIT_LOG_MASK_ASSET:W}" diff --git a/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java b/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java index 218d2e64aa..67efdcdb8b 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java @@ -128,7 +128,7 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC String accessToken = deviceCredentials.getCredentialsId(); assertNotNull(accessToken); - String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}"; + String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1},\"timeout\": 6000}"; String deviceId = savedDevice.getId().getId().toString(); doPostAsync("/api/plugins/rpc/oneway/" + deviceId, setGpioRequest, String.class, status().isRequestTimeout(), @@ -183,7 +183,7 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC String accessToken = deviceCredentials.getCredentialsId(); assertNotNull(accessToken); - String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}"; + String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1},\"timeout\": 6000}"; String deviceId = savedDevice.getId().getId().toString(); doPostAsync("/api/plugins/rpc/twoway/" + deviceId, setGpioRequest, String.class, status().isRequestTimeout(), diff --git a/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java b/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java index 8e8639c50c..50de5727f9 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/telemetry/AbstractMqttTelemetryIntegrationTest.java @@ -111,7 +111,7 @@ public abstract class AbstractMqttTelemetryIntegrationTest extends AbstractContr client.subscribe("v1/devices/me/attributes", MqttQoS.AT_MOST_ONCE.value()); String payload = "{\"key\":\"value\"}"; String result = doPostAsync("/api/plugins/telemetry/" + savedDevice.getId() + "/SHARED_SCOPE", payload, String.class, status().isOk()); - latch.await(3, TimeUnit.SECONDS); + latch.await(10, TimeUnit.SECONDS); assertEquals(payload, callback.getPayload()); assertEquals(MqttQoS.AT_MOST_ONCE.value(), callback.getQoS()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java index 519607b840..d1395f43db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java @@ -18,11 +18,7 @@ package org.thingsboard.server.dao; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.dao.model.ToData; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.List; -import java.util.UUID; +import java.util.*; public abstract class DaoUtil { @@ -50,6 +46,14 @@ public abstract class DaoUtil { return object; } + public static T getData(Optional> data) { + T object = null; + if (data.isPresent()) { + object = data.get().toData(); + } + return object; + } + public static UUID getId(UUIDBased idBased) { UUID id = null; if (idBased != null) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java index c5518bef9d..ad3db8781f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java @@ -60,7 +60,7 @@ import static org.thingsboard.server.dao.service.Validator.validateId; @Slf4j @Service -@ConditionalOnProperty(prefix = "audit_log", value = "enabled", havingValue = "true") +@ConditionalOnProperty(prefix = "audit-log", value = "enabled", havingValue = "true") public class AuditLogServiceImpl implements AuditLogService { private static final ObjectMapper objectMapper = new ObjectMapper(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/CassandraAuditLogDao.java b/dao/src/main/java/org/thingsboard/server/dao/audit/CassandraAuditLogDao.java index 860e0a962e..6ffaf1ea00 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/CassandraAuditLogDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/CassandraAuditLogDao.java @@ -88,11 +88,11 @@ public class CassandraAuditLogDao extends CassandraAbstractSearchTimeDao redisTemplate(RedisConnectionFactory cf) { - RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setConnectionFactory(cf); - return redisTemplate; - } - - @Bean - public CacheManager cacheManager(RedisTemplate redisTemplate) { - return new RedisCacheManager(redisTemplate); + public CacheManager cacheManager(RedisConnectionFactory cf) { + DefaultFormattingConversionService redisConversionService = new DefaultFormattingConversionService(); + RedisCacheConfiguration.registerDefaultConverters(redisConversionService); + registerDefaultConverters(redisConversionService); + RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig().withConversionService(redisConversionService); + return RedisCacheManager.builder(cf).cacheDefaults(configuration).build(); } @Bean @@ -73,5 +93,8 @@ public class TBRedisCacheConfiguration { return new PreviousDeviceCredentialsIdKeyGenerator(); } - + private static void registerDefaultConverters(ConverterRegistry registry) { + Assert.notNull(registry, "ConverterRegistry must not be null!"); + registry.addConverter(EntityId.class, String.class, EntityId::toString); + } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java index 84073f62cd..4fa383656b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraCluster.java @@ -21,7 +21,7 @@ import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PostConstruct; -@Component +@Component("CassandraCluster") @NoSqlAnyDao public class CassandraCluster extends AbstractCassandraCluster { diff --git a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java index 54365e5281..2492552c99 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraInstallCluster.java @@ -21,7 +21,7 @@ import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PostConstruct; -@Component +@Component("CassandraInstallCluster") @NoSqlAnyDao @Profile("install") public class CassandraInstallCluster extends AbstractCassandraCluster { diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AuditLogEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AuditLogEntity.java index 813017ff8e..de0ec87359 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AuditLogEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AuditLogEntity.java @@ -140,7 +140,7 @@ public class AuditLogEntity extends BaseSqlEntity implements BaseEntit auditLog.setEntityId(EntityIdFactory.getByTypeAndId(entityType.name(), toUUID(entityId).toString())); } if (userId != null) { - auditLog.setUserId(new UserId(toUUID(entityId))); + auditLog.setUserId(new UserId(toUUID(userId))); } auditLog.setEntityName(this.entityName); auditLog.setUserName(this.userName); diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractDao.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractDao.java index b0840140e3..09d4e53928 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractDao.java @@ -27,6 +27,7 @@ import com.datastax.driver.core.TypeCodec; import com.datastax.driver.core.exceptions.CodecNotFoundException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.cassandra.CassandraCluster; import org.thingsboard.server.dao.model.type.AuthorityCodec; @@ -44,6 +45,7 @@ import java.util.concurrent.ConcurrentMap; public abstract class CassandraAbstractDao { @Autowired + @Qualifier("CassandraCluster") protected CassandraCluster cluster; private ConcurrentMap preparedStatementMap = new ConcurrentHashMap<>(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java index 3b0bec9064..34c0a7cb25 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDao.java @@ -27,6 +27,7 @@ import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.model.BaseEntity; import java.util.List; +import java.util.Optional; import java.util.UUID; import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID; @@ -67,23 +68,23 @@ public abstract class JpaAbstractDao, D> @Override public D findById(TenantId tenantId, UUID key) { log.debug("Get entity by key {}", key); - E entity = getCrudRepository().findOne(fromTimeUUID(key)); + Optional entity = getCrudRepository().findById(fromTimeUUID(key)); return DaoUtil.getData(entity); } @Override public ListenableFuture findByIdAsync(TenantId tenantId, UUID key) { log.debug("Get entity by key async {}", key); - return service.submit(() -> DaoUtil.getData(getCrudRepository().findOne(fromTimeUUID(key)))); + return service.submit(() -> DaoUtil.getData(getCrudRepository().findById(fromTimeUUID(key)))); } @Override @Transactional public boolean removeById(TenantId tenantId, UUID id) { String key = fromTimeUUID(id); - getCrudRepository().delete(key); + getCrudRepository().deleteById(key); log.debug("Remove request: {}", key); - return getCrudRepository().findOne(key) == null; + return !getCrudRepository().existsById(key); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java index c2cfee19d3..dc65018cf1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java @@ -52,7 +52,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl AttributeKvCompositeKey compositeKey = getAttributeKvCompositeKey(entityId, attributeType, attributeKey); return Futures.immediateFuture( - Optional.ofNullable(DaoUtil.getData(attributeKvRepository.findOne(compositeKey)))); + Optional.ofNullable(DaoUtil.getData(attributeKvRepository.findById(compositeKey)))); } @Override @@ -64,7 +64,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl getAttributeKvCompositeKey(entityId, attributeType, attributeKey)) .collect(Collectors.toList()); return Futures.immediateFuture( - DaoUtil.convertDataList(Lists.newArrayList(attributeKvRepository.findAll(compositeKeys)))); + DaoUtil.convertDataList(Lists.newArrayList(attributeKvRepository.findAllById(compositeKeys)))); } @Override @@ -103,7 +103,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl }).collect(Collectors.toList()); return service.submit(() -> { - attributeKvRepository.delete(entitiesToDelete); + attributeKvRepository.deleteAll(entitiesToDelete); return null; }); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDao.java index 1cc7104365..c66ea9c5cc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/component/JpaBaseComponentDescriptorDao.java @@ -66,7 +66,7 @@ public class JpaBaseComponentDescriptorDao extends JpaAbstractSearchTextDao checkRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup) { RelationCompositeKey key = getRelationCompositeKey(from, to, relationType, typeGroup); - return service.submit(() -> relationRepository.findOne(key) != null); + return service.submit(() -> relationRepository.existsById(key)); } @Override public ListenableFuture getRelation(TenantId tenantId, EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup) { RelationCompositeKey key = getRelationCompositeKey(from, to, relationType, typeGroup); - return service.submit(() -> DaoUtil.getData(relationRepository.findOne(key))); + return service.submit(() -> DaoUtil.getData(relationRepository.findById(key))); } private RelationCompositeKey getRelationCompositeKey(EntityId from, EntityId to, String relationType, RelationTypeGroup typeGroup) { @@ -152,9 +152,9 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple } private boolean deleteRelationIfExists(RelationCompositeKey key) { - boolean relationExistsBeforeDelete = relationRepository.exists(key); + boolean relationExistsBeforeDelete = relationRepository.existsById(key); if (relationExistsBeforeDelete) { - relationRepository.delete(key); + relationRepository.deleteById(key); } return relationExistsBeforeDelete; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java index 208b63a0b0..e2fb1ff213 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/RelationRepository.java @@ -53,7 +53,7 @@ public interface RelationRepository RelationEntity save(RelationEntity entity); @Transactional - void delete(RelationCompositeKey id); + void deleteById(RelationCompositeKey id); @Transactional void deleteByFromIdAndFromType(String fromId, String fromType); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java index 92e5c1db0d..2b36eb80b9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java @@ -284,10 +284,10 @@ public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService imp entityId.getEntityType(), fromTimeUUID(entityId.getId()), key); - TsKvLatestEntity entry = tsKvLatestRepository.findOne(compositeKey); + Optional entry = tsKvLatestRepository.findById(compositeKey); TsKvEntry result; - if (entry != null) { - result = DaoUtil.getData(entry); + if (entry.isPresent()) { + result = DaoUtil.getData(entry.get()); } else { result = new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(key, null)); } diff --git a/dao/src/test/java/org/thingsboard/server/dao/CustomCassandraCQLUnit.java b/dao/src/test/java/org/thingsboard/server/dao/CustomCassandraCQLUnit.java index b57cd33c6c..c74dbd54e5 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/CustomCassandraCQLUnit.java +++ b/dao/src/test/java/org/thingsboard/server/dao/CustomCassandraCQLUnit.java @@ -82,6 +82,7 @@ public class CustomCassandraCQLUnit extends BaseCassandraUnit { session = null; cluster = null; } + System.setSecurityManager(null); } // Getters for those who do not like to directly access fields diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index 7440a966fe..d8f18e090f 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -4,10 +4,10 @@ zk.zk_dir=/thingsboard updates.enabled=false -audit_log.enabled=true -audit_log.by_tenant_partitioning=MONTHS -audit_log.default_query_period=30 -audit_log.sink.type=none +audit-log.enabled=true +audit-log.by_tenant_partitioning=MONTHS +audit-log.default_query_period=30 +audit-log.sink.type=none cache.type=caffeine #cache.type=redis diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties index 3357425fce..745aa9e1e0 100644 --- a/dao/src/test/resources/sql-test.properties +++ b/dao/src/test/resources/sql-test.properties @@ -4,6 +4,7 @@ database.entities.type=sql sql.ts_inserts_executor_type=fixed sql.ts_inserts_fixed_thread_pool_size=10 +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=validate spring.jpa.database-platform=org.hibernate.dialect.HSQLDialect diff --git a/k8s/.env b/k8s/.env new file mode 100644 index 0000000000..e50e57d9b8 --- /dev/null +++ b/k8s/.env @@ -0,0 +1,5 @@ + +# Database used by ThingsBoard, can be either postgres (PostgreSQL) or cassandra (Cassandra). +# According to the database type corresponding kubernetes resources will be deployed (see postgres.yml, cassandra.yml for details). + +DATABASE=postgres diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000000..e57790ffc4 --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,100 @@ +# Kubernetes resources configuration for ThingsBoard Microservices + +This folder containing scripts and Kubernetes resources configurations to run ThingsBoard in Microservices mode. + +## Prerequisites + +ThingsBoard Microservices are running on Kubernetes cluster. +You need to have a Kubernetes cluster, and the kubectl command-line tool must be configured to communicate with your cluster. +If you do not already have a cluster, you can create one by using [Minikube](https://kubernetes.io/docs/setup/minikube), +or you can choose any other available [Kubernetes cluster deployment solutions](https://kubernetes.io/docs/setup/pick-right-solution/). + +## Installation + +Before performing initial installation you can configure the type of database to be used with ThingsBoard. +In order to set database type change the value of `DATABASE` variable in `.env` file to one of the following: + +- `postgres` - use PostgreSQL database; +- `cassandra` - use Cassandra database; + +**NOTE**: According to the database type corresponding kubernetes resources will be deployed (see `postgres.yml`, `cassandra.yml` for details). + +Execute the following command to run installation: + +` +$ ./k8s-install-tb.sh --loadDemo +` + +Where: + +- `--loadDemo` - optional argument. Whether to load additional demo data. + +## Running + +Execute the following command to deploy resources: + +` +$ ./k8s-deploy-resources.sh +` + +After a while when all resources will be successfully started you can open `http://{your-cluster-ip}` in you browser (for ex. `http://192.168.99.101`). +You should see ThingsBoard login page. + +Use the following default credentials: + +- **System Administrator**: sysadmin@thingsboard.org / sysadmin + +If you installed DataBase with demo data (using `--loadDemo` flag) you can also use the following credentials: + +- **Tenant Administrator**: tenant@thingsboard.org / tenant +- **Customer User**: customer@thingsboard.org / customer + +In case of any issues you can examine service logs for errors. +For example to see ThingsBoard node logs execute the following commands: + +1) Get list of the running tb-node pods: + +` +$ kubectl get pods -l app=tb-node +` + +2) Fetch logs of tb-node pod: + +` +$ kubectl logs -f [tb-node-pod-name] +` + +Where: + +- `tb-node-pod-name` - tb-node pod name obtained from the list of the running tb-node pods. + +Or use `kubectl get pods` to see the state of all the pods. +Or use `kubectl get services` to see the state of all the services. +Or use `kubectl get deployments` to see the state of all the deployments. +See [kubectl Cheat Sheet](https://kubernetes.io/docs/reference/kubectl/cheatsheet/) command reference for details. + +Execute the following command to delete all deployed microservices: + +` +$ ./k8s-delete-resources.sh +` + +Execute the following command to delete all resources (including database): + +` +$ ./k8s-delete-all.sh +` + +## Upgrading + +In case when database upgrade is needed, execute the following commands: + +``` +$ ./k8s-delete-resources.sh +$ ./k8s-upgrade-tb.sh --fromVersion=[FROM_VERSION] +$ ./k8s-deploy-resources.sh +``` + +Where: + +- `FROM_VERSION` - from which version upgrade should be started. See [Upgrade Instructions](https://thingsboard.io/docs/user-guide/install/upgrade-instructions) for valid `fromVersion` values. diff --git a/k8s/cassandra.yml b/k8s/cassandra.yml new file mode 100644 index 0000000000..aa1d18022c --- /dev/null +++ b/k8s/cassandra.yml @@ -0,0 +1,164 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: storage.k8s.io/v1 +kind: StorageClass +metadata: + name: fast + namespace: thingsboard +provisioner: k8s.io/minikube-hostpath +parameters: + type: pd-ssd +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: cassandra-probe-config + namespace: thingsboard + labels: + name: cassandra-probe-config +data: + probe: | + if [[ $(nodetool status | grep $POD_IP) == *"UN"* ]]; then + if [[ $DEBUG ]]; then + echo "UN"; + fi + exit 0; + else + if [[ $DEBUG ]]; then + echo "Not Up"; + fi + exit 1; + fi +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: cassandra + namespace: thingsboard + labels: + app: cassandra +spec: + serviceName: cassandra + replicas: 1 + selector: + matchLabels: + app: cassandra + template: + metadata: + labels: + app: cassandra + spec: + volumes: + - name: cassandra-probe-config + configMap: + name: cassandra-probe-config + items: + - key: probe + path: ready-probe.sh + mode: 0777 + terminationGracePeriodSeconds: 1800 + containers: + - name: cassandra + image: cassandra:3.11.3 + imagePullPolicy: Always + ports: + - containerPort: 7000 + name: intra-node + - containerPort: 7001 + name: tls-intra-node + - containerPort: 7199 + name: jmx + - containerPort: 9042 + name: cql + - containerPort: 9160 + name: thrift + resources: + limits: + cpu: "1000m" + memory: 2Gi + requests: + cpu: "1000m" + memory: 2Gi + securityContext: + capabilities: + add: + - IPC_LOCK + lifecycle: + preStop: + exec: + command: + - /bin/sh + - -c + - nodetool drain + env: + - name: CASSANDRA_SEEDS + value: "cassandra-0.cassandra.thingsboard.svc.cluster.local" + - name: MAX_HEAP_SIZE + value: 1024M + - name: HEAP_NEWSIZE + value: 256M + - name: CASSANDRA_CLUSTER_NAME + value: "Thingsboard Cluster" + - name: CASSANDRA_DC + value: "DC1-Thingsboard-Cluster" + - name: CASSANDRA_RACK + value: "Rack-Thingsboard-Cluster" + - name: CASSANDRA_AUTO_BOOTSTRAP + value: "false" + - name: CASSANDRA_ENDPOINT_SNITCH + value: GossipingPropertyFileSnitch + - name: POD_IP + valueFrom: + fieldRef: + fieldPath: status.podIP + readinessProbe: + exec: + command: + - /bin/bash + - -c + - /probe/ready-probe.sh + initialDelaySeconds: 60 + timeoutSeconds: 5 + volumeMounts: + - name: cassandra-probe-config + mountPath: /probe + - name: cassandra-data + mountPath: /var/lib/cassandra + volumeClaimTemplates: + - metadata: + name: cassandra-data + spec: + accessModes: [ "ReadWriteOnce" ] + storageClassName: fast + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app: cassandra + name: cassandra + namespace: thingsboard +spec: + clusterIP: None + ports: + - port: 9042 + selector: + app: cassandra +--- diff --git a/k8s/database-setup.yml b/k8s/database-setup.yml new file mode 100644 index 0000000000..d73a685b74 --- /dev/null +++ b/k8s/database-setup.yml @@ -0,0 +1,43 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: Pod +metadata: + name: tb-db-setup + namespace: thingsboard +spec: + volumes: + - name: tb-node-config + configMap: + name: tb-node-config + items: + - key: conf + path: thingsboard.conf + - key: logback + path: logback.xml + containers: + - name: tb-db-setup + imagePullPolicy: Always + image: thingsboard/tb-node:latest + envFrom: + - configMapRef: + name: tb-node-db-config + volumeMounts: + - mountPath: /config + name: tb-node-config + command: ['sh', '-c', 'while [ ! -f /install-finished ]; do sleep 2; done;'] + restartPolicy: Never diff --git a/k8s/k8s-delete-all.sh b/k8s/k8s-delete-all.sh new file mode 100755 index 0000000000..a90829368e --- /dev/null +++ b/k8s/k8s-delete-all.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +kubectl -n thingsboard delete svc,sts,deploy,pv,pvc,cm,po,ing --all --include-uninitialized diff --git a/k8s/k8s-delete-resources.sh b/k8s/k8s-delete-resources.sh new file mode 100755 index 0000000000..87f25ebedd --- /dev/null +++ b/k8s/k8s-delete-resources.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +kubectl config set-context $(kubectl config current-context) --namespace=thingsboard +kubectl delete -f thingsboard.yml diff --git a/k8s/k8s-deploy-resources.sh b/k8s/k8s-deploy-resources.sh new file mode 100755 index 0000000000..86ec235dd7 --- /dev/null +++ b/k8s/k8s-deploy-resources.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +kubectl apply -f tb-namespace.yml +kubectl config set-context $(kubectl config current-context) --namespace=thingsboard +kubectl apply -f tb-node-configmap.yml +kubectl apply -f tb-mqtt-transport-configmap.yml +kubectl apply -f tb-http-transport-configmap.yml +kubectl apply -f tb-coap-transport-configmap.yml +kubectl apply -f thingsboard.yml diff --git a/k8s/k8s-install-tb.sh b/k8s/k8s-install-tb.sh new file mode 100755 index 0000000000..9fa9c0ea3a --- /dev/null +++ b/k8s/k8s-install-tb.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +function installTb() { + + loadDemo=$1 + + kubectl apply -f tb-node-configmap.yml + kubectl apply -f database-setup.yml && + kubectl wait --for=condition=Ready pod/tb-db-setup --timeout=120s && + kubectl exec tb-db-setup -- sh -c 'export INSTALL_TB=true; export LOAD_DEMO='"$loadDemo"'; start-tb-node.sh; touch /install-finished;' + + kubectl delete pod tb-db-setup + +} + +function installPostgres() { + + kubectl apply -f postgres.yml + kubectl apply -f tb-node-postgres-configmap.yml + + kubectl rollout status deployment/postgres +} + +function installCassandra() { + + kubectl apply -f cassandra.yml + kubectl apply -f tb-node-cassandra-configmap.yml + + kubectl rollout status statefulset/cassandra + + kubectl exec -it cassandra-0 -- bash -c "cqlsh -e \ + \"CREATE KEYSPACE IF NOT EXISTS thingsboard \ + WITH replication = { \ + 'class' : 'SimpleStrategy', \ + 'replication_factor' : 1 \ + };\"" +} + +while [[ $# -gt 0 ]] +do +key="$1" + +case $key in + --loadDemo) + LOAD_DEMO=true + shift # past argument + ;; + *) + # unknown option + ;; +esac +shift # past argument or value +done + +if [ "$LOAD_DEMO" == "true" ]; then + loadDemo=true +else + loadDemo=false +fi + +source .env + +kubectl apply -f tb-namespace.yml +kubectl config set-context $(kubectl config current-context) --namespace=thingsboard + +case $DATABASE in + postgres) + installPostgres + installTb ${loadDemo} + ;; + cassandra) + installCassandra + installTb ${loadDemo} + ;; + *) + echo "Unknown DATABASE value specified: '${DATABASE}'. Should be either postgres or cassandra." >&2 + exit 1 +esac diff --git a/k8s/k8s-upgrade-tb.sh b/k8s/k8s-upgrade-tb.sh new file mode 100755 index 0000000000..35dc11f5d1 --- /dev/null +++ b/k8s/k8s-upgrade-tb.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +for i in "$@" +do +case $i in + --fromVersion=*) + FROM_VERSION="${i#*=}" + shift + ;; + *) + # unknown option + ;; +esac +done + +if [[ -z "${FROM_VERSION// }" ]]; then + echo "--fromVersion parameter is invalid or unspecified!" + echo "Usage: k8s-upgrade-tb.sh --fromVersion={VERSION}" + exit 1 +else + fromVersion="${FROM_VERSION// }" +fi + +kubectl apply -f database-setup.yml && +kubectl wait --for=condition=Ready pod/tb-db-setup --timeout=120s && +kubectl exec tb-db-setup -- sh -c 'export UPGRADE_TB=true; export FROM_VERSION='"$fromVersion"'; start-tb-node.sh; touch /install-finished;' + +kubectl delete pod tb-db-setup diff --git a/k8s/postgres.yml b/k8s/postgres.yml new file mode 100644 index 0000000000..2e7f2cc815 --- /dev/null +++ b/k8s/postgres.yml @@ -0,0 +1,95 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-pv-claim + namespace: thingsboard + labels: + app: postgres +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 5Gi +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: postgres + namespace: thingsboard + labels: + app: postgres +spec: + template: + metadata: + labels: + app: postgres + spec: + volumes: + - name: postgres-data + persistentVolumeClaim: + claimName: postgres-pv-claim + containers: + - name: postgres + imagePullPolicy: Always + image: postgres:9.6 + ports: + - containerPort: 5432 + name: postgres + env: + - name: POSTGRES_DB + value: "thingsboard" + volumeMounts: + - mountPath: /var/lib/postgresql/data + name: postgres-data + livenessProbe: + exec: + command: + - pg_isready + - -h + - localhost + - -U + - postgres + initialDelaySeconds: 60 + timeoutSeconds: 30 + readinessProbe: + exec: + command: + - pg_isready + - -h + - localhost + - -U + - postgres + initialDelaySeconds: 5 + timeoutSeconds: 1 + restartPolicy: Always +--- +apiVersion: v1 +kind: Service +metadata: + name: tb-database + namespace: thingsboard +spec: + type: ClusterIP + selector: + app: postgres + ports: + - port: 5432 + name: postgres +--- diff --git a/k8s/tb-coap-transport-configmap.yml b/k8s/tb-coap-transport-configmap.yml new file mode 100644 index 0000000000..5381f766f4 --- /dev/null +++ b/k8s/tb-coap-transport-configmap.yml @@ -0,0 +1,65 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: ConfigMap +metadata: + name: tb-coap-transport-config + namespace: thingsboard + labels: + name: tb-coap-transport-config +data: + conf: | + export JAVA_OPTS="$JAVA_OPTS -Xloggc:/var/log/tb-coap-transport/${TB_HOST}/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tb-coap-transport/${TB_HOST}/heapdump.bin -XX:+PrintGCDetails -XX:+PrintGCDateStamps" + export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" + export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" + export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" + export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExitOnOutOfMemoryError" + export LOG_FILENAME=tb-coap-transport.out + export LOADER_PATH=/usr/share/tb-coap-transport/conf + logback: | + + + + + /var/log/tb-coap-transport/${TB_HOST}/tb-coap-transport.log + + /var/log/tb-coap-transport/${TB_HOST}/tb-coap-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/k8s/tb-http-transport-configmap.yml b/k8s/tb-http-transport-configmap.yml new file mode 100644 index 0000000000..5376b82cef --- /dev/null +++ b/k8s/tb-http-transport-configmap.yml @@ -0,0 +1,65 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: ConfigMap +metadata: + name: tb-http-transport-config + namespace: thingsboard + labels: + name: tb-http-transport-config +data: + conf: | + export JAVA_OPTS="$JAVA_OPTS -Xloggc:/var/log/tb-http-transport/${TB_HOST}/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tb-http-transport/${TB_HOST}/heapdump.bin -XX:+PrintGCDetails -XX:+PrintGCDateStamps" + export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" + export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" + export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" + export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExitOnOutOfMemoryError" + export LOG_FILENAME=tb-http-transport.out + export LOADER_PATH=/usr/share/tb-http-transport/conf + logback: | + + + + + /var/log/tb-http-transport/${TB_HOST}/tb-http-transport.log + + /var/log/tb-http-transport/${TB_HOST}/tb-http-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/k8s/tb-mqtt-transport-configmap.yml b/k8s/tb-mqtt-transport-configmap.yml new file mode 100644 index 0000000000..1e73a08db7 --- /dev/null +++ b/k8s/tb-mqtt-transport-configmap.yml @@ -0,0 +1,65 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: ConfigMap +metadata: + name: tb-mqtt-transport-config + namespace: thingsboard + labels: + name: tb-mqtt-transport-config +data: + conf: | + export JAVA_OPTS="$JAVA_OPTS -Xloggc:/var/log/tb-mqtt-transport/${TB_HOST}/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/tb-mqtt-transport/${TB_HOST}/heapdump.bin -XX:+PrintGCDetails -XX:+PrintGCDateStamps" + export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" + export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" + export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" + export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExitOnOutOfMemoryError" + export LOG_FILENAME=tb-mqtt-transport.out + export LOADER_PATH=/usr/share/tb-mqtt-transport/conf + logback: | + + + + + /var/log/tb-mqtt-transport/${TB_HOST}/tb-mqtt-transport.log + + /var/log/tb-mqtt-transport/${TB_HOST}/tb-mqtt-transport.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + diff --git a/k8s/tb-namespace.yml b/k8s/tb-namespace.yml new file mode 100644 index 0000000000..113009635f --- /dev/null +++ b/k8s/tb-namespace.yml @@ -0,0 +1,22 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: Namespace +metadata: + name: thingsboard + labels: + name: thingsboard diff --git a/k8s/tb-node-cassandra-configmap.yml b/k8s/tb-node-cassandra-configmap.yml new file mode 100644 index 0000000000..21a05bd6f6 --- /dev/null +++ b/k8s/tb-node-cassandra-configmap.yml @@ -0,0 +1,28 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: ConfigMap +metadata: + name: tb-node-db-config + namespace: thingsboard + labels: + name: tb-node-db-config +data: + DATABASE_TS_TYPE: cassandra + DATABASE_ENTITIES_TYPE: cassandra + CASSANDRA_URL: cassandra:9042 + CASSANDRA_SOCKET_READ_TIMEOUT: "60000" diff --git a/k8s/tb-node-configmap.yml b/k8s/tb-node-configmap.yml new file mode 100644 index 0000000000..550a0665d1 --- /dev/null +++ b/k8s/tb-node-configmap.yml @@ -0,0 +1,67 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: ConfigMap +metadata: + name: tb-node-config + namespace: thingsboard + labels: + name: tb-node-config +data: + conf: | + export JAVA_OPTS="$JAVA_OPTS -Dplatform=deb -Dinstall.data_dir=/usr/share/thingsboard/data" + export JAVA_OPTS="$JAVA_OPTS -Xloggc:/var/log/thingsboard/${TB_HOST}/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/thingsboard/${TB_HOST}/heapdump.bin -XX:+PrintGCDetails -XX:+PrintGCDateStamps" + export JAVA_OPTS="$JAVA_OPTS -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10" + export JAVA_OPTS="$JAVA_OPTS -XX:GCLogFileSize=10M -XX:-UseBiasedLocking -XX:+UseTLAB -XX:+ResizeTLAB -XX:+PerfDisableSharedMem -XX:+UseCondCardMark" + export JAVA_OPTS="$JAVA_OPTS -XX:CMSWaitDuration=10000 -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+CMSParallelRemarkEnabled -XX:+CMSParallelInitialMarkEnabled" + export JAVA_OPTS="$JAVA_OPTS -XX:+CMSEdenChunksRecordAlways -XX:CMSInitiatingOccupancyFraction=75 -XX:+UseCMSInitiatingOccupancyOnly -XX:+ExitOnOutOfMemoryError" + export LOG_FILENAME=thingsboard.out + export LOADER_PATH=/usr/share/thingsboard/conf,/usr/share/thingsboard/extensions + logback: | + + + + + /var/log/thingsboard/${TB_HOST}/thingsboard.log + + /var/log/thingsboard/${TB_HOST}/thingsboard.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + diff --git a/k8s/tb-node-postgres-configmap.yml b/k8s/tb-node-postgres-configmap.yml new file mode 100644 index 0000000000..3e07cb863c --- /dev/null +++ b/k8s/tb-node-postgres-configmap.yml @@ -0,0 +1,31 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: v1 +kind: ConfigMap +metadata: + name: tb-node-db-config + namespace: thingsboard + labels: + name: tb-node-db-config +data: + DATABASE_TS_TYPE: sql + DATABASE_ENTITIES_TYPE: sql + SPRING_JPA_DATABASE_PLATFORM: org.hibernate.dialect.PostgreSQLDialect + SPRING_DRIVER_CLASS_NAME: org.postgresql.Driver + SPRING_DATASOURCE_URL: jdbc:postgresql://tb-database:5432/thingsboard + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: postgres diff --git a/k8s/thingsboard.yml b/k8s/thingsboard.yml new file mode 100644 index 0000000000..0022e72d77 --- /dev/null +++ b/k8s/thingsboard.yml @@ -0,0 +1,608 @@ +# +# Copyright © 2016-2019 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: zookeeper + namespace: thingsboard +spec: + template: + metadata: + labels: + app: zookeeper + spec: + containers: + - name: server + imagePullPolicy: Always + image: zookeeper:3.5 + ports: + - containerPort: 2181 + readinessProbe: + periodSeconds: 5 + tcpSocket: + port: 2181 + livenessProbe: + periodSeconds: 5 + tcpSocket: + port: 2181 + env: + - name: ZOO_MY_ID + value: "1" + - name: ZOO_SERVERS + value: "server.1=0.0.0.0:2888:3888;0.0.0.0:2181" + restartPolicy: Always +--- +apiVersion: v1 +kind: Service +metadata: + name: zookeeper + namespace: thingsboard +spec: + type: ClusterIP + selector: + app: zookeeper + ports: + - name: zk-port + port: 2181 +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: tb-kafka + namespace: thingsboard +spec: + template: + metadata: + labels: + app: tb-kafka + spec: + containers: + - name: server + imagePullPolicy: Always + image: wurstmeister/kafka + ports: + - containerPort: 9092 + readinessProbe: + periodSeconds: 20 + tcpSocket: + port: 9092 + livenessProbe: + periodSeconds: 5 + tcpSocket: + port: 9092 + env: + - name: KAFKA_ZOOKEEPER_CONNECT + value: "zookeeper:2181" + - name: KAFKA_LISTENERS + value: "INSIDE://:9093,OUTSIDE://:9092" + - name: KAFKA_ADVERTISED_LISTENERS + value: "INSIDE://:9093,OUTSIDE://tb-kafka:9092" + - name: KAFKA_LISTENER_SECURITY_PROTOCOL_MAP + value: "INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT" + - name: KAFKA_INTER_BROKER_LISTENER_NAME + value: "INSIDE" + - name: KAFKA_CREATE_TOPICS + value: "js.eval.requests:100:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.transport.api.requests:30:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600,tb.rule-engine:30:1:delete --config=retention.ms=60000 --config=segment.bytes=26214400 --config=retention.bytes=104857600" + - name: KAFKA_AUTO_CREATE_TOPICS_ENABLE + value: "false" + - name: KAFKA_LOG_RETENTION_BYTES + value: "1073741824" + - name: KAFKA_LOG_SEGMENT_BYTES + value: "268435456" + - name: KAFKA_LOG_RETENTION_MS + value: "300000" + - name: KAFKA_LOG_CLEANUP_POLICY + value: "delete" + restartPolicy: Always +--- +apiVersion: v1 +kind: Service +metadata: + name: tb-kafka + namespace: thingsboard +spec: + type: ClusterIP + selector: + app: tb-kafka + ports: + - name: tb-kafka-port + port: 9092 +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: tb-redis + namespace: thingsboard +spec: + template: + metadata: + labels: + app: tb-redis + spec: + containers: + - name: server + imagePullPolicy: Always + image: redis:4.0 + ports: + - containerPort: 6379 + readinessProbe: + periodSeconds: 5 + tcpSocket: + port: 6379 + livenessProbe: + periodSeconds: 5 + tcpSocket: + port: 6379 + volumeMounts: + - mountPath: /data + name: redis-data + volumes: + - name: redis-data + emptyDir: {} + restartPolicy: Always +--- +apiVersion: v1 +kind: Service +metadata: + name: tb-redis + namespace: thingsboard +spec: + type: ClusterIP + selector: + app: tb-redis + ports: + - name: tb-redis-port + port: 6379 +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: tb-js-executor + namespace: thingsboard +spec: + replicas: 20 + selector: + matchLabels: + app: tb-js-executor + template: + metadata: + labels: + app: tb-js-executor + spec: + containers: + - name: server + imagePullPolicy: Always + image: thingsboard/tb-js-executor:latest + env: + - name: REMOTE_JS_EVAL_REQUEST_TOPIC + value: "js.eval.requests" + - name: TB_KAFKA_SERVERS + value: "tb-kafka:9092" + - name: LOGGER_LEVEL + value: "info" + - name: LOG_FOLDER + value: "logs" + - name: LOGGER_FILENAME + value: "tb-js-executor-%DATE%.log" + - name: DOCKER_MODE + value: "true" + - name: SCRIPT_BODY_TRACE_FREQUENCY + value: "1000" + restartPolicy: Always +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: tb-node + namespace: thingsboard +spec: + replicas: 2 + selector: + matchLabels: + app: tb-node + template: + metadata: + labels: + app: tb-node + spec: + volumes: + - name: tb-node-config + configMap: + name: tb-node-config + items: + - key: conf + path: thingsboard.conf + - key: logback + path: logback.xml + containers: + - name: server + imagePullPolicy: Always + image: thingsboard/tb-node:latest + ports: + - containerPort: 8080 + name: http + - containerPort: 9001 + name: rpc + env: + - name: RPC_HOST + valueFrom: + fieldRef: + fieldPath: status.podIP + - name: CLUSTER_NODE_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: TB_HOST + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: ZOOKEEPER_ENABLED + value: "true" + - name: ZOOKEEPER_URL + value: "zookeeper:2181" + - name: TB_KAFKA_SERVERS + value: "tb-kafka:9092" + - name: JS_EVALUATOR + value: "remote" + - name: TRANSPORT_TYPE + value: "remote" + - name: CACHE_TYPE + value: "redis" + - name: REDIS_HOST + value: "tb-redis" + - name: HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE + value: "false" + envFrom: + - configMapRef: + name: tb-node-db-config + volumeMounts: + - mountPath: /config + name: tb-node-config + livenessProbe: + httpGet: + path: /login + port: http + initialDelaySeconds: 120 + timeoutSeconds: 10 + restartPolicy: Always +--- +apiVersion: v1 +kind: Service +metadata: + name: tb-node + namespace: thingsboard +spec: + type: ClusterIP + selector: + app: tb-node + ports: + - port: 8080 + name: http +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: tb-mqtt-transport + namespace: thingsboard +spec: + replicas: 2 + selector: + matchLabels: + app: tb-mqtt-transport + template: + metadata: + labels: + app: tb-mqtt-transport + spec: + volumes: + - name: tb-mqtt-transport-config + configMap: + name: tb-mqtt-transport-config + items: + - key: conf + path: tb-mqtt-transport.conf + - key: logback + path: logback.xml + containers: + - name: server + imagePullPolicy: Always + image: thingsboard/tb-mqtt-transport:latest + ports: + - containerPort: 1883 + name: mqtt + env: + - name: CLUSTER_NODE_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: TB_HOST + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: MQTT_BIND_ADDRESS + value: "0.0.0.0" + - name: MQTT_BIND_PORT + value: "1883" + - name: MQTT_TIMEOUT + value: "10000" + - name: TB_KAFKA_SERVERS + value: "tb-kafka:9092" + volumeMounts: + - mountPath: /config + name: tb-mqtt-transport-config + readinessProbe: + periodSeconds: 20 + tcpSocket: + port: 1883 + livenessProbe: + periodSeconds: 20 + tcpSocket: + port: 1883 + restartPolicy: Always +--- +apiVersion: v1 +kind: Service +metadata: + name: tb-mqtt-transport + namespace: thingsboard +spec: + type: LoadBalancer + selector: + app: tb-mqtt-transport + ports: + - port: 1883 + targetPort: 1883 + name: mqtt +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: tb-http-transport + namespace: thingsboard +spec: + replicas: 2 + selector: + matchLabels: + app: tb-http-transport + template: + metadata: + labels: + app: tb-http-transport + spec: + volumes: + - name: tb-http-transport-config + configMap: + name: tb-http-transport-config + items: + - key: conf + path: tb-http-transport.conf + - key: logback + path: logback.xml + containers: + - name: server + imagePullPolicy: Always + image: thingsboard/tb-http-transport:latest + ports: + - containerPort: 8080 + name: http + env: + - name: CLUSTER_NODE_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: TB_HOST + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: HTTP_BIND_ADDRESS + value: "0.0.0.0" + - name: HTTP_BIND_PORT + value: "8080" + - name: HTTP_REQUEST_TIMEOUT + value: "60000" + - name: TB_KAFKA_SERVERS + value: "tb-kafka:9092" + volumeMounts: + - mountPath: /config + name: tb-http-transport-config + readinessProbe: + periodSeconds: 20 + tcpSocket: + port: 8080 + livenessProbe: + periodSeconds: 20 + tcpSocket: + port: 8080 + restartPolicy: Always +--- +apiVersion: v1 +kind: Service +metadata: + name: tb-http-transport + namespace: thingsboard +spec: + type: ClusterIP + selector: + app: tb-http-transport + ports: + - port: 8080 + name: http +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: tb-coap-transport + namespace: thingsboard +spec: + replicas: 2 + selector: + matchLabels: + app: tb-coap-transport + template: + metadata: + labels: + app: tb-coap-transport + spec: + volumes: + - name: tb-coap-transport-config + configMap: + name: tb-coap-transport-config + items: + - key: conf + path: tb-coap-transport.conf + - key: logback + path: logback.xml + containers: + - name: server + imagePullPolicy: Always + image: thingsboard/tb-coap-transport:latest + ports: + - containerPort: 5683 + name: coap + protocol: UDP + env: + - name: CLUSTER_NODE_ID + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: TB_HOST + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: COAP_BIND_ADDRESS + value: "0.0.0.0" + - name: COAP_BIND_PORT + value: "5683" + - name: COAP_TIMEOUT + value: "10000" + - name: TB_KAFKA_SERVERS + value: "tb-kafka:9092" + volumeMounts: + - mountPath: /config + name: tb-coap-transport-config + restartPolicy: Always +--- +apiVersion: v1 +kind: Service +metadata: + name: tb-coap-transport + namespace: thingsboard +spec: + type: LoadBalancer + selector: + app: tb-coap-transport + ports: + - port: 5683 + name: coap + protocol: UDP +--- +apiVersion: extensions/v1beta1 +kind: Deployment +metadata: + name: tb-web-ui + namespace: thingsboard +spec: + replicas: 2 + selector: + matchLabels: + app: tb-web-ui + template: + metadata: + labels: + app: tb-web-ui + spec: + containers: + - name: server + imagePullPolicy: Always + image: thingsboard/tb-web-ui:latest + ports: + - containerPort: 8080 + name: http + env: + - name: HTTP_BIND_ADDRESS + value: "0.0.0.0" + - name: HTTP_BIND_PORT + value: "8080" + - name: TB_ENABLE_PROXY + value: "false" + - name: LOGGER_LEVEL + value: "info" + - name: LOG_FOLDER + value: "logs" + - name: LOGGER_FILENAME + value: "tb-web-ui-%DATE%.log" + - name: DOCKER_MODE + value: "true" + livenessProbe: + httpGet: + path: /index.html + port: http + initialDelaySeconds: 120 + timeoutSeconds: 10 + restartPolicy: Always +--- +apiVersion: v1 +kind: Service +metadata: + name: tb-web-ui + namespace: thingsboard +spec: + type: ClusterIP + selector: + app: tb-web-ui + ports: + - port: 8080 + name: http +--- +apiVersion: extensions/v1beta1 +kind: Ingress +metadata: + name: tb-ingress + namespace: thingsboard + annotations: + nginx.ingress.kubernetes.io/use-regex: "true" + nginx.ingress.kubernetes.io/ssl-redirect: "false" + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" +spec: + rules: + - http: + paths: + - path: /api/v1/.* + backend: + serviceName: tb-http-transport + servicePort: 8080 + - path: /static/rulenode/.* + backend: + serviceName: tb-node + servicePort: 8080 + - path: /static/.* + backend: + serviceName: tb-web-ui + servicePort: 8080 + - path: /index.html.* + backend: + serviceName: tb-web-ui + servicePort: 8080 + - path: / + backend: + serviceName: tb-web-ui + servicePort: 8080 + - path: /.* + backend: + serviceName: tb-node + servicePort: 8080 +--- \ No newline at end of file diff --git a/msa/js-executor/package-lock.json b/msa/js-executor/package-lock.json index 1af16309a7..a01d4a9053 100644 --- a/msa/js-executor/package-lock.json +++ b/msa/js-executor/package-lock.json @@ -1,6 +1,6 @@ { "name": "thingsboard-js-executor", - "version": "2.3.0", + "version": "2.3.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/msa/web-ui/package-lock.json b/msa/web-ui/package-lock.json index c50ee7d073..16d10f5961 100644 --- a/msa/web-ui/package-lock.json +++ b/msa/web-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "thingsboard-web-ui", - "version": "2.3.0", + "version": "2.3.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/pom.xml b/pom.xml index f2cde239b7..b0bf2a51c3 100755 --- a/pom.xml +++ b/pom.xml @@ -29,10 +29,10 @@ ${basedir} - 1.4.3.RELEASE - 4.3.4.RELEASE - 4.2.0.RELEASE - 1.8.10.RELEASE + 2.1.3.RELEASE + 5.1.5.RELEASE + 5.1.4.RELEASE + 2.1.5.RELEASE 2.9.0 0.7.0 2.2.0 @@ -41,8 +41,8 @@ 1.2.3 1.9.5 0.10 - 3.5.0 - 3.3.0.2 + 3.6.0 + 3.5.0.1 1.2.7 21.0 2.6.1 @@ -50,7 +50,7 @@ 1.5.0 2.5 1.4 - 2.8.11.1 + 2.9.7 2.2.6 2.11 2.4.2 @@ -60,11 +60,11 @@ 2.0 1.4.3 4.0.1 - 3.0.2 - 1.12.0 + 3.6.1 + 1.16.1 1.16.18 1.1.0 - 4.1.22.Final + 4.1.30.Final 1.5.0 4.8.0 2.19.1 @@ -85,6 +85,8 @@ 2.0.0 4.1.1 2.57 + 2.7.7 + 1.23 @@ -512,6 +514,16 @@ + + org.yaml + snakeyaml + ${snakeyaml.version} + + + antlr + antlr + ${antlr.version} + com.rabbitmq amqp-client @@ -600,6 +612,16 @@ jackson-databind ${jackson.version} + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.version} + com.github.fge json-schema-validator @@ -794,6 +816,12 @@ de.ruedigermoeller fst ${fst.version} + + + com.fasterxml.jackson.core + jackson-core + + io.springfox.ui diff --git a/ui/package-lock.json b/ui/package-lock.json index 9b8224e0ff..39846f4c4e 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -468,9 +468,9 @@ "integrity": "sha512-o+V/OzwNGpS30QmgP7DJWTdBJ2BMDut481qqB72sM0L59dkO6TNjRV7qubQCntGqGe98h9vObweQUVYTfEO4vg==" }, "angular-material": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/angular-material/-/angular-material-1.1.9.tgz", - "integrity": "sha512-kxyigi+7823k/31qQ0j6wL5FkCe/mw2bAg1kfEFzIvhUoe5Myr+0YoQyN8D8EGaaOyolXU/VPtxgKSfOCSLEBw==" + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/angular-material/-/angular-material-1.1.13.tgz", + "integrity": "sha512-qWc5WOhRa/sbQmiRwenOla2Pky3w+wgW0l5Wp3J6jmB/WWxMWW7+JMdCXo1diGEETTKTF2vLdeWTceDTNehmSw==" }, "angular-material-data-table": { "version": "0.10.10", @@ -7675,6 +7675,22 @@ } } }, + "jstree": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/jstree/-/jstree-3.3.7.tgz", + "integrity": "sha512-yzzalO1TbZ4HdPezO43LesGI4Wv2sB0Nl+4GfwO0YYvehGws5qtTAhlBISxfur9phMLwCtf9GjHlRx2ZLXyRnw==", + "requires": { + "jquery": ">=1.9.1" + } + }, + "jstree-bootstrap-theme": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/jstree-bootstrap-theme/-/jstree-bootstrap-theme-1.0.1.tgz", + "integrity": "sha1-fV7cc6hG6Np/lPV6HMXd7p2eq0s=", + "requires": { + "jquery": ">=1.9.1" + } + }, "keycode": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz", diff --git a/ui/package.json b/ui/package.json index 2600c62438..0a1f4512a1 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,7 +27,7 @@ "angular-gridster": "^0.13.14", "angular-hotkeys": "^1.7.0", "angular-jwt": "^0.1.6", - "angular-material": "1.1.9", + "angular-material": "1.1.13", "angular-material-data-table": "^0.10.9", "angular-material-expansion-panel": "^0.7.2", "angular-material-icons": "^0.7.1", @@ -60,6 +60,8 @@ "jquery.terminal": "^1.5.0", "js-beautify": "^1.6.4", "json-schema-defaults": "^0.2.0", + "jstree": "^3.3.7", + "jstree-bootstrap-theme": "^1.0.1", "leaflet": "^1.0.3", "leaflet-providers": "^1.1.17", "material-ui": "^0.16.1", diff --git a/ui/src/app/api/entity-relation.service.js b/ui/src/app/api/entity-relation.service.js index ad5dc63da4..9294ca39d4 100644 --- a/ui/src/app/api/entity-relation.service.js +++ b/ui/src/app/api/entity-relation.service.js @@ -164,13 +164,13 @@ function EntityRelationService($http, $q) { return deferred.promise; } - function findByQuery(query) { + function findByQuery(query, config) { var deferred = $q.defer(); var url = '/api/relations'; - $http.post(url, query).then(function success(response) { + $http.post(url, query, config).then(function success(response) { deferred.resolve(response.data); - }, function fail() { - deferred.reject(); + }, function fail(e) { + deferred.reject(e); }); return deferred.promise; } diff --git a/ui/src/app/api/user.service.js b/ui/src/app/api/user.service.js index 3349558204..ffcdc5f896 100644 --- a/ui/src/app/api/user.service.js +++ b/ui/src/app/api/user.service.js @@ -362,6 +362,25 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, time $location.search('publicId', null); deferred.reject(); }); + } else if (locationSearch.accessToken) { + var token = locationSearch.accessToken; + var refreshToken = locationSearch.refreshToken; + $location.search('accessToken', null); + if (refreshToken) { + $location.search('refreshToken', null); + } + try { + updateAndValidateToken(token, 'jwt_token', false); + if (refreshToken) { + updateAndValidateToken(refreshToken, 'refresh_token', false); + } else { + store.remove('refresh_token'); + store.remove('refresh_token_expiration'); + } + } catch (e) { + deferred.reject(); + } + procceedJwtTokenValidate(); } else { procceedJwtTokenValidate(); } diff --git a/ui/src/app/api/widget.service.js b/ui/src/app/api/widget.service.js index a32d2b0f5c..70cf76ff1a 100644 --- a/ui/src/app/api/widget.service.js +++ b/ui/src/app/api/widget.service.js @@ -21,6 +21,7 @@ import thingsboardLedLight from '../components/led-light.directive'; import thingsboardTimeseriesTableWidget from '../widget/lib/timeseries-table-widget'; import thingsboardAlarmsTableWidget from '../widget/lib/alarms-table-widget'; import thingsboardEntitiesTableWidget from '../widget/lib/entities-table-widget'; +import thingsboardEntitiesHierarchyWidget from '../widget/lib/entities-hierarchy-widget'; import thingsboardExtensionsTableWidget from '../widget/lib/extensions-table-widget'; import thingsboardRpcWidgets from '../widget/lib/rpc'; @@ -32,6 +33,7 @@ import TbAnalogueCompass from '../widget/lib/analogue-compass'; import TbCanvasDigitalGauge from '../widget/lib/canvas-digital-gauge'; import TbMapWidget from '../widget/lib/map-widget'; import TbMapWidgetV2 from '../widget/lib/map-widget2'; +import TripAnimationWidget from '../widget/lib/tripAnimation/trip-animation-widget'; import 'jquery.terminal/js/jquery.terminal.min.js'; import 'jquery.terminal/css/jquery.terminal.min.css'; @@ -43,7 +45,7 @@ import thingsboardTypes from '../common/types.constant'; import thingsboardUtils from '../common/utils.service'; export default angular.module('thingsboard.api.widget', ['oc.lazyLoad', thingsboardLedLight, thingsboardTimeseriesTableWidget, - thingsboardAlarmsTableWidget, thingsboardEntitiesTableWidget, thingsboardExtensionsTableWidget, thingsboardRpcWidgets, thingsboardTypes, thingsboardUtils]) + thingsboardAlarmsTableWidget, thingsboardEntitiesTableWidget, thingsboardEntitiesHierarchyWidget, thingsboardExtensionsTableWidget, thingsboardRpcWidgets, thingsboardTypes, thingsboardUtils, TripAnimationWidget]) .factory('widgetService', WidgetService) .name; diff --git a/ui/src/app/app.js b/ui/src/app/app.js index dbef8a9c6b..7081415ff5 100644 --- a/ui/src/app/app.js +++ b/ui/src/app/app.js @@ -52,7 +52,8 @@ import 'react-schema-form'; import react from 'ngreact'; import '@flowjs/ng-flow/dist/ng-flow-standalone.min'; import 'ngFlowchart/dist/ngFlowchart'; - +import 'jstree/dist/jstree.min'; +import 'jstree-bootstrap-theme/dist/themes/proton/style.min.css'; import 'typeface-roboto'; import 'font-awesome/css/font-awesome.min.css'; import 'angular-material/angular-material.min.css'; diff --git a/ui/src/app/components/json-form.directive.js b/ui/src/app/components/json-form.directive.js index 1b460b6238..80f8a6cdda 100644 --- a/ui/src/app/components/json-form.directive.js +++ b/ui/src/app/components/json-form.directive.js @@ -71,7 +71,10 @@ function JsonForm($compile, $templateCache, $mdColorPicker) { $compile(element.contents())(childScope); } + scope.isFullscreen = false; + scope.formProps = { + isFullscreen: false, option: { formDefaults: { startEmpty: true @@ -86,6 +89,10 @@ function JsonForm($compile, $templateCache, $mdColorPicker) { }, onColorClick: function(event, key, val) { scope.showColorPicker(event, val); + }, + onToggleFullscreen: function() { + scope.isFullscreen = !scope.isFullscreen; + scope.formProps.isFullscreen = scope.isFullscreen; } }; @@ -116,6 +123,8 @@ function JsonForm($compile, $templateCache, $mdColorPicker) { }); } + scope.onFullscreenChanged = function() {} + scope.validate = function(){ if (scope.schema && scope.model) { var result = utils.validateBySchema(scope.schema, scope.model); diff --git a/ui/src/app/components/json-form.tpl.html b/ui/src/app/components/json-form.tpl.html index c15f3f5eb1..f5096c25b7 100644 --- a/ui/src/app/components/json-form.tpl.html +++ b/ui/src/app/components/json-form.tpl.html @@ -15,4 +15,6 @@ limitations under the License. --> - \ No newline at end of file +
+ +
diff --git a/ui/src/app/components/nav-tree.directive.js b/ui/src/app/components/nav-tree.directive.js new file mode 100644 index 0000000000..119ef98529 --- /dev/null +++ b/ui/src/app/components/nav-tree.directive.js @@ -0,0 +1,206 @@ +/* + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import './nav-tree.scss'; + +/* eslint-disable import/no-unresolved, import/default */ + +import navTreeTemplate from './nav-tree.tpl.html'; + +/* eslint-enable import/no-unresolved, import/default */ + +export default angular.module('thingsboard.directives.navTree', []) + .directive('tbNavTree', NavTree) + .name; + +/*@ngInject*/ +function NavTree() { + return { + restrict: "E", + scope: true, + bindToController: { + loadNodes: '=', + editCallbacks: '=', + enableSearch: '@?', + onNodeSelected: '&', + onNodesInserted: '&', + searchCallback: '&?' + }, + controller: NavTreeController, + controllerAs: 'vm', + templateUrl: navTreeTemplate + }; +} + +/*@ngInject*/ +function NavTreeController($scope, $element, types) { + + var vm = this; + vm.types = types; + + $scope.$watch('vm.loadNodes', (newVal) => { + if (newVal) { + initTree(); + } + }); + + function initTree() { + var config = { + core: { + multiple: false, + check_callback: true, + themes: { name: 'proton', responsive: true }, + data: vm.loadNodes + } + }; + + if (vm.enableSearch) { + config.plugins = ["search"]; + config.search = { + case_sensitive: false, + show_only_matches: true, + show_only_matches_children: false, + search_leaves_only: false + }; + if (vm.searchCallback) { + config.search.search_callback = (searchText, node) => vm.searchCallback({searchText: searchText, node: node}); + } + } + + vm.treeElement = angular.element('.tb-nav-tree-container', $element) + .jstree(config); + + vm.treeElement.on("changed.jstree", function (e, data) { + if (vm.onNodeSelected) { + vm.onNodeSelected({node: data.instance.get_selected(true)[0], event: e}); + } + }); + + vm.treeElement.on("model.jstree", function (e, data) { + if (vm.onNodesInserted) { + vm.onNodesInserted({nodes: data.nodes, parent: data.parent}); + } + }); + + if (vm.editCallbacks) { + vm.editCallbacks.selectNode = (id) => { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + vm.treeElement.jstree('deselect_all', true); + vm.treeElement.jstree('select_node', node); + } + }; + vm.editCallbacks.deselectAll = () => { + vm.treeElement.jstree('deselect_all'); + }; + vm.editCallbacks.getNode = (id) => { + var node = vm.treeElement.jstree('get_node', id); + return node; + }; + vm.editCallbacks.getParentNodeId = (id) => { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + return vm.treeElement.jstree('get_parent', node); + } + }; + vm.editCallbacks.openNode = (id, cb) => { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + vm.treeElement.jstree('open_node', node, cb); + } + }; + vm.editCallbacks.nodeIsOpen = (id) => { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + return vm.treeElement.jstree('is_open', node); + } else { + return true; + } + }; + vm.editCallbacks.nodeIsLoaded = (id) => { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + return vm.treeElement.jstree('is_loaded', node); + } else { + return true; + } + }; + vm.editCallbacks.refreshNode = (id) => { + if (id === '#') { + vm.treeElement.jstree('refresh'); + vm.treeElement.jstree('redraw'); + } else { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + var opened = vm.treeElement.jstree('is_open', node); + vm.treeElement.jstree('refresh_node', node); + vm.treeElement.jstree('redraw'); + if (node.children && opened/* && !node.children.length*/) { + vm.treeElement.jstree('open_node', node); + } + } + } + }; + vm.editCallbacks.updateNode = (id, newName) => { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + vm.treeElement.jstree('rename_node', node, newName); + } + }; + vm.editCallbacks.createNode = (parentId, node, pos) => { + var parentNode = vm.treeElement.jstree('get_node', parentId); + if (parentNode) { + vm.treeElement.jstree('create_node', parentNode, node, pos); + } + }; + vm.editCallbacks.deleteNode = (id) => { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + vm.treeElement.jstree('delete_node', node); + } + }; + vm.editCallbacks.disableNode = (id) => { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + vm.treeElement.jstree('disable_node', node); + } + }; + vm.editCallbacks.enableNode = (id) => { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + vm.treeElement.jstree('enable_node', node); + } + }; + vm.editCallbacks.setNodeHasChildren = (id, hasChildren) => { + var node = vm.treeElement.jstree('get_node', id); + if (node) { + if (!node.children || !node.children.length) { + node.children = hasChildren; + node.state.loaded = !hasChildren; + node.state.opened = false; + vm.treeElement.jstree('_node_changed', node.id); + vm.treeElement.jstree('redraw'); + } + } + }; + vm.editCallbacks.search = (searchText) => { + vm.treeElement.jstree('search', searchText); + }; + vm.editCallbacks.clearSearch = () => { + vm.treeElement.jstree('clear_search'); + }; + } + } +} diff --git a/ui/src/app/components/nav-tree.scss b/ui/src/app/components/nav-tree.scss new file mode 100644 index 0000000000..4a9ac4739b --- /dev/null +++ b/ui/src/app/components/nav-tree.scss @@ -0,0 +1,346 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +.tb-nav-tree-container { + padding: 15px; + font-family: Roboto, "Helvetica Neue", sans-serif; + + &.jstree-proton { + .jstree-node, + .jstree-icon { + background-image: url("../../png/jstree/32px.png"); + } + + .jstree-last { + background: transparent; + } + + .jstree-themeicon-custom { + background-image: none; + + &.material-icons { + font-size: 18px; + } + } + + .jstree-anchor { + font-size: 16px; + } + } + + &.jstree-proton-small { + .jstree-node, + .jstree-icon { + background-image: url("../../png/jstree/32px.png"); + } + + .jstree-last { + background: transparent; + } + + .jstree-themeicon-custom { + background-image: none; + + &.material-icons { + font-size: 14px; + } + } + + .jstree-anchor { + font-size: 14px; + } + } + + &.jstree-proton-large { + .jstree-node, + .jstree-icon { + background-image: url("../../png/jstree/32px.png"); + } + + .jstree-last { + background: transparent; + } + + .jstree-themeicon-custom { + background-image: none; + + &.material-icons { + font-size: 24px; + } + } + + .jstree-anchor { + font-size: 20px; + } + } + + a { + border-bottom: none; + + i.jstree-themeicon-custom { + &.tb-user-group { + &::before { + content: "account_circle"; + } + } + + &.tb-customer-group { + &::before { + content: "supervisor_account"; + } + } + + &.tb-asset-group { + &::before { + content: "domain"; + } + } + + &.tb-device-group { + &::before { + content: "devices_other"; + } + } + + &.tb-entity-view-group { + &::before { + content: "view_quilt"; + } + } + + &.tb-dashboard-group { + &::before { + content: "dashboard"; + } + } + + &.tb-customer { + &::before { + content: "supervisor_account"; + } + } + } + } +} + +@media (max-width: 768px) { + .tb-nav-tree-container { + &.jstree-proton-responsive { + .jstree-node, + .jstree-icon, + .jstree-node > .jstree-ocl, + .jstree-themeicon, + .jstree-checkbox { + background-image: url("../../png/jstree/40px.png"); + background-size: 120px 240px; + } + + .jstree-container-ul { + overflow: visible; + } + + .jstree-themeicon-custom { + background-color: transparent; + background-image: none; + background-position: 0 0; + + &.material-icons { + margin: 0; + font-size: 24px; + } + } + + .jstree-node, + .jstree-leaf > .jstree-ocl { + background: 0 0; + } + + .jstree-node { + min-width: 40px; + min-height: 40px; + margin-left: 40px; + line-height: 40px; + white-space: nowrap; + background-repeat: repeat-y; + background-position: -80px 0; + } + + .jstree-last { + background: 0 0; + } + + .jstree-anchor { + height: 40px; + font-size: 1.1em; + font-weight: 700; + line-height: 40px; + text-shadow: 1px 1px #fff; + } + + .jstree-icon, + .jstree-icon:empty { + width: 40px; + height: 40px; + line-height: 40px; + } + + > { + .jstree-container-ul > .jstree-node { + margin-right: 0; + margin-left: 0; + } + } + + .jstree-ocl, + .jstree-themeicon, + .jstree-checkbox { + background-size: 120px 240px; + } + + .jstree-leaf > .jstree-ocl { + background: 0 0; + background-position: -40px -120px; + } + + .jstree-last > .jstree-ocl { + background-position: -40px -160px; + } + + .jstree-open > .jstree-ocl { + background-position: 0 0 !important; + } + + .jstree-closed > .jstree-ocl { + background-position: 0 -40px !important; + } + + .jstree-themeicon { + background-position: -40px -40px; + } + + .jstree-checkbox, + .jstree-checkbox:hover { + background-position: -40px -80px; + } + + &.jstree-checkbox-selection { + .jstree-clicked > .jstree-checkbox, + .jstree-clicked > .jstree-checkbox:hover { + background-position: 0 -80px; + } + } + + .jstree-checked > .jstree-checkbox, + .jstree-checked > .jstree-checkbox:hover { + background-position: 0 -80px; + } + + .jstree-anchor > .jstree-undetermined, + .jstree-anchor > .jstree-undetermined:hover { + background-position: 0 -120px; + } + + .jstree-striped { + background: 0 0; + } + + .jstree-wholerow { + height: 40px; + background: #ebebeb; + border-top: 1px solid rgba(255, 255, 255, .7); + border-bottom: 1px solid rgba(64, 64, 64, .2); + } + + .jstree-wholerow-hovered { + background: #e7f4f9; + } + + .jstree-wholerow-clicked { + background: #beebff; + } + + .jstree-children { + .jstree-last > .jstree-wholerow { + box-shadow: inset 0 -6px 3px -5px #666; + } + + .jstree-open > .jstree-wholerow { + border-top: 0; + box-shadow: inset 0 6px 3px -5px #666; + } + + .jstree-open + .jstree-open { + box-shadow: none; + } + } + + &.jstree-rtl { + .jstree-node { + margin-right: 40px; + margin-left: 0; + } + + .jstree-container-ul > .jstree-node { + margin-right: 0; + } + + .jstree-closed > .jstree-ocl { + background-position: -40px 0 !important; + } + } + } + } +} + +.tb-nav-tree .md-button.tb-active { + font-weight: 500; + background-color: rgba(255, 255, 255, .15); +} + +.tb-nav-tree, +.tb-nav-tree ul { + margin-top: 0; + list-style: none; + + &:first-child { + padding: 0; + } + + li { + .md-button { + width: 100%; + max-height: 40px; + padding: 0 16px; + margin: 0; + overflow: hidden; + line-height: 40px; + color: inherit; + text-align: left; + text-decoration: none; + text-overflow: ellipsis; + text-transform: none; + text-rendering: optimizeLegibility; + white-space: nowrap; + cursor: pointer; + border-radius: 0; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } + } +} diff --git a/ui/src/app/components/nav-tree.tpl.html b/ui/src/app/components/nav-tree.tpl.html new file mode 100644 index 0000000000..88612965ff --- /dev/null +++ b/ui/src/app/components/nav-tree.tpl.html @@ -0,0 +1,18 @@ + +
diff --git a/ui/src/app/components/react/json-form-ace-editor.jsx b/ui/src/app/components/react/json-form-ace-editor.jsx index a1f576bc43..44df6cd38d 100644 --- a/ui/src/app/components/react/json-form-ace-editor.jsx +++ b/ui/src/app/components/react/json-form-ace-editor.jsx @@ -34,8 +34,10 @@ class ThingsboardAceEditor extends React.Component { this.onFocus = this.onFocus.bind(this); this.onTidy = this.onTidy.bind(this); this.onLoad = this.onLoad.bind(this); + this.onToggleFull = this.onToggleFull.bind(this); var value = props.value ? props.value + '' : ''; this.state = { + isFull: false, value: value, focused: false }; @@ -76,9 +78,26 @@ class ThingsboardAceEditor extends React.Component { } onLoad(editor) { + this.aceEditor = editor; fixAceEditor(editor); } + onToggleFull() { + this.setState({ isFull: !this.state.isFull }); + this.props.onToggleFullscreen(); + this.updateAceEditorSize = true; + } + + componentDidUpdate() { + if (this.updateAceEditorSize) { + if (this.aceEditor) { + this.aceEditor.resize(); + this.aceEditor.renderer.updateFull(); + } + this.updateAceEditorSize = false; + } + } + render() { const styles = reactCSS({ @@ -108,18 +127,23 @@ class ThingsboardAceEditor extends React.Component { if (this.state.focused) { labelClass += " tb-focused"; } - + var containerClass = "tb-container"; + var style = this.props.form.style || {width: '100%'}; + if (this.state.isFull) { + containerClass += " fullscreen-form-field"; + } return ( -
+
+
+ style={style}/>
{this.props.error}
+ style={{opacity: this.props.valid ? '0' : '1'}}>{this.props.error}
); } diff --git a/ui/src/app/components/react/json-form-ace-editor.scss b/ui/src/app/components/react/json-form-ace-editor.scss index 3ae4530b9c..fa32b74a07 100644 --- a/ui/src/app/components/react/json-form-ace-editor.scss +++ b/ui/src/app/components/react/json-form-ace-editor.scss @@ -13,6 +13,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +.fullscreen-form-field { + .json-form-ace-editor { + height: calc(100% - 60px); + } +} + .json-form-ace-editor { position: relative; height: 100%; diff --git a/ui/src/app/components/react/json-form-array.jsx b/ui/src/app/components/react/json-form-array.jsx index b42bac55a8..46f6457b0f 100644 --- a/ui/src/app/components/react/json-form-array.jsx +++ b/ui/src/app/components/react/json-form-array.jsx @@ -131,7 +131,7 @@ class ThingsboardArray extends React.Component { } let forms = this.props.form.items.map(function(form, index){ var copy = this.copyWithIndex(form, i); - return this.props.builder(copy, this.props.model, index, this.props.onChange, this.props.onColorClick, this.props.mapper, this.props.builder); + return this.props.builder(copy, this.props.model, index, this.props.onChange, this.props.onColorClick, this.props.onToggleFullscreen, this.props.mapper, this.props.builder); }.bind(this)); arrays.push(
  • diff --git a/ui/src/app/components/react/json-form-fieldset.jsx b/ui/src/app/components/react/json-form-fieldset.jsx index 8434025c7b..5a0f94017c 100644 --- a/ui/src/app/components/react/json-form-fieldset.jsx +++ b/ui/src/app/components/react/json-form-fieldset.jsx @@ -19,7 +19,7 @@ class ThingsboardFieldSet extends React.Component { render() { let forms = this.props.form.items.map(function(form, index){ - return this.props.builder(form, this.props.model, index, this.props.onChange, this.props.onColorClick, this.props.mapper, this.props.builder); + return this.props.builder(form, this.props.model, index, this.props.onChange, this.props.onColorClick, this.props.onToggleFullscreen, this.props.mapper, this.props.builder); }.bind(this)); return ( diff --git a/ui/src/app/components/react/json-form-react.jsx b/ui/src/app/components/react/json-form-react.jsx index 59d007532c..18b2de26ef 100644 --- a/ui/src/app/components/react/json-form-react.jsx +++ b/ui/src/app/components/react/json-form-react.jsx @@ -50,7 +50,8 @@ ReactSchemaForm.propTypes = { model: React.PropTypes.object, option: React.PropTypes.object, onModelChange: React.PropTypes.func, - onColorClick: React.PropTypes.func + onColorClick: React.PropTypes.func, + onToggleFullscreen: React.PropTypes.func } ReactSchemaForm.defaultProps = { diff --git a/ui/src/app/components/react/json-form-schema-form.jsx b/ui/src/app/components/react/json-form-schema-form.jsx index 4c3aefe66e..47d20426b7 100644 --- a/ui/src/app/components/react/json-form-schema-form.jsx +++ b/ui/src/app/components/react/json-form-schema-form.jsx @@ -63,6 +63,7 @@ class ThingsboardSchemaForm extends React.Component { this.onChange = this.onChange.bind(this); this.onColorClick = this.onColorClick.bind(this); + this.onToggleFullscreen = this.onToggleFullscreen.bind(this); this.hasConditions = false; } @@ -78,7 +79,11 @@ class ThingsboardSchemaForm extends React.Component { this.props.onColorClick(event, key, val); } - builder(form, model, index, onChange, onColorClick, mapper) { + onToggleFullscreen() { + this.props.onToggleFullscreen(); + } + + builder(form, model, index, onChange, onColorClick, onToggleFullscreen, mapper) { var type = form.type; let Field = this.mapper[type]; if(!Field) { @@ -91,7 +96,7 @@ class ThingsboardSchemaForm extends React.Component { return null; } } - return + return } render() { @@ -101,11 +106,16 @@ class ThingsboardSchemaForm extends React.Component { mapper = _.merge(this.mapper, this.props.mapper); } let forms = merged.map(function(form, index) { - return this.builder(form, this.props.model, index, this.onChange, this.onColorClick, mapper); + return this.builder(form, this.props.model, index, this.onChange, this.onColorClick, this.onToggleFullscreen, mapper); }.bind(this)); + let formClass = 'SchemaForm'; + if (this.props.isFullscreen) { + formClass += ' SchemaFormFullscreen'; + } + return ( -
    {forms}
    +
    {forms}
    ); } } diff --git a/ui/src/app/components/react/json-form.scss b/ui/src/app/components/react/json-form.scss index eabf2725ef..d36ec5c0e8 100644 --- a/ui/src/app/components/react/json-form.scss +++ b/ui/src/app/components/react/json-form.scss @@ -21,6 +21,24 @@ $swift-ease-out-timing-function: cubic-bezier(.25, .8, .25, 1) !default; $input-label-float-offset: 6px !default; $input-label-float-scale: .75 !default; +.SchemaForm { + &.SchemaFormFullscreen { + position: relative; + width: 100%; + height: 100%; + + > div:not(.fullscreen-form-field) { + display: none; + } + + > div.fullscreen-form-field { + position: relative; + width: 100%; + height: 100%; + } + } +} + .json-form-error { position: relative; bottom: -5px; diff --git a/ui/src/app/layout/index.js b/ui/src/app/layout/index.js index bb450490a8..c0ef817a3b 100644 --- a/ui/src/app/layout/index.js +++ b/ui/src/app/layout/index.js @@ -27,6 +27,7 @@ import thingsboardApiUser from '../api/user.service'; import thingsboardNoAnimate from '../components/no-animate.directive'; import thingsboardOnFinishRender from '../components/finish-render.directive'; import thingsboardSideMenu from '../components/side-menu.directive'; +import thingsboardNavTree from '../components/nav-tree.directive'; import thingsboardDashboardAutocomplete from '../components/dashboard-autocomplete.directive'; import thingsboardKvMap from '../components/kv-map.directive'; import thingsboardJsonObjectEdit from '../components/json-object-edit.directive'; @@ -89,6 +90,7 @@ export default angular.module('thingsboard.home', [ thingsboardNoAnimate, thingsboardOnFinishRender, thingsboardSideMenu, + thingsboardNavTree, thingsboardDashboardAutocomplete, thingsboardKvMap, thingsboardJsonObjectEdit, diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 9e38c55aaa..dfef502f09 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -1566,7 +1566,8 @@ "row-click": "On row click", "polygon-click": "On polygon click", "marker-click": "On marker click", - "tooltip-tag-action": "Tooltip tag action" + "tooltip-tag-action": "Tooltip tag action", + "node-selected": "On node selected" } }, "language": { diff --git a/ui/src/app/locale/translate-handler.js b/ui/src/app/locale/translate-handler.js index a3e76a9c9b..21d80ec327 100644 --- a/ui/src/app/locale/translate-handler.js +++ b/ui/src/app/locale/translate-handler.js @@ -18,12 +18,12 @@ .name; /*@ngInject*/ -function ThingsboardMissingTranslateHandler($log, types) { +function ThingsboardMissingTranslateHandler(/*$log, types*/) { - return function (translationId) { - if (translationId && !translationId.startsWith(types.translate.customTranslationsPrefix)) { + return function (/*translationId*/) { + /*if (translationId && !translationId.startsWith(types.translate.customTranslationsPrefix)) { $log.warn('Translation for ' + translationId + ' doesn\'t exist'); - } + }*/ }; } \ No newline at end of file diff --git a/ui/src/app/widget/lib/entities-hierarchy-widget.js b/ui/src/app/widget/lib/entities-hierarchy-widget.js new file mode 100644 index 0000000000..93b3912709 --- /dev/null +++ b/ui/src/app/widget/lib/entities-hierarchy-widget.js @@ -0,0 +1,530 @@ +/* + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import './entities-hierarchy-widget.scss'; + +/* eslint-disable import/no-unresolved, import/default */ + +import entitiesHierarchyWidgetTemplate from './entities-hierarchy-widget.tpl.html'; + +/* eslint-enable import/no-unresolved, import/default */ + +export default angular.module('thingsboard.widgets.entitiesHierarchyWidget', []) + .directive('tbEntitiesHierarchyWidget', EntitiesHierarchyWidget) + .name; + +/*@ngInject*/ +function EntitiesHierarchyWidget() { + return { + restrict: "E", + scope: true, + bindToController: { + hierarchyId: '=', + ctx: '=' + }, + controller: EntitiesHierarchyWidgetController, + controllerAs: 'vm', + templateUrl: entitiesHierarchyWidgetTemplate + }; +} + +/*@ngInject*/ +function EntitiesHierarchyWidgetController($element, $scope, $q, $timeout, toast, types, entityService, entityRelationService /*$filter, $mdMedia, $mdPanel, $document, $translate, $timeout, utils, types*/) { + var vm = this; + + vm.showData = true; + + vm.nodeEditCallbacks = {}; + + vm.nodeIdCounter = 0; + + vm.nodesMap = {}; + vm.pendingUpdateNodeTasks = {}; + + vm.query = { + search: null + }; + + vm.searchAction = { + name: 'action.search', + show: true, + onAction: function() { + vm.enterFilterMode(); + }, + icon: 'search' + }; + + vm.onNodesInserted = onNodesInserted; + vm.onNodeSelected = onNodeSelected; + vm.enterFilterMode = enterFilterMode; + vm.exitFilterMode = exitFilterMode; + vm.searchCallback = searchCallback; + + $scope.$watch('vm.ctx', function() { + if (vm.ctx && vm.ctx.defaultSubscription) { + vm.settings = vm.ctx.settings; + vm.widgetConfig = vm.ctx.widgetConfig; + vm.subscription = vm.ctx.defaultSubscription; + vm.datasources = vm.subscription.datasources; + initializeConfig(); + updateDatasources(); + } + }); + + $scope.$watch("vm.query.search", function(newVal, prevVal) { + if (!angular.equals(newVal, prevVal) && vm.query.search != null) { + updateSearchNodes(); + } + }); + + $scope.$on('entities-hierarchy-data-updated', function(event, hierarchyId) { + if (vm.hierarchyId == hierarchyId) { + if (vm.subscription) { + updateNodeData(vm.subscription.data); + } + } + }); + + function initializeConfig() { + + vm.ctx.widgetActions = [ vm.searchAction ]; + + var testNodeCtx = { + entity: { + id: { + entityType: 'DEVICE', + id: '123' + }, + name: 'TEST DEV1' + }, + data: {}, + level: 2 + }; + var parentNodeCtx = angular.copy(testNodeCtx); + parentNodeCtx.level = 1; + testNodeCtx.parentNodeCtx = parentNodeCtx; + + var nodeRelationQueryFunction = loadNodeCtxFunction(vm.settings.nodeRelationQueryFunction, 'nodeCtx', testNodeCtx); + var nodeIconFunction = loadNodeCtxFunction(vm.settings.nodeIconFunction, 'nodeCtx', testNodeCtx); + var nodeTextFunction = loadNodeCtxFunction(vm.settings.nodeTextFunction, 'nodeCtx', testNodeCtx); + var nodeDisabledFunction = loadNodeCtxFunction(vm.settings.nodeDisabledFunction, 'nodeCtx', testNodeCtx); + var nodeOpenedFunction = loadNodeCtxFunction(vm.settings.nodeOpenedFunction, 'nodeCtx', testNodeCtx); + var nodeHasChildrenFunction = loadNodeCtxFunction(vm.settings.nodeHasChildrenFunction, 'nodeCtx', testNodeCtx); + + var testNodeCtx2 = angular.copy(testNodeCtx); + testNodeCtx2.entity.name = 'TEST DEV2'; + + var nodesSortFunction = loadNodeCtxFunction(vm.settings.nodesSortFunction, 'nodeCtx1,nodeCtx2', testNodeCtx, testNodeCtx2); + + vm.nodeRelationQueryFunction = nodeRelationQueryFunction || defaultNodeRelationQueryFunction; + vm.nodeIconFunction = nodeIconFunction || defaultNodeIconFunction; + vm.nodeTextFunction = nodeTextFunction || ((nodeCtx) => nodeCtx.entity.name); + vm.nodeDisabledFunction = nodeDisabledFunction || (() => false); + vm.nodeOpenedFunction = nodeOpenedFunction || defaultNodeOpenedFunction; + vm.nodeHasChildrenFunction = nodeHasChildrenFunction || (() => true); + vm.nodesSortFunction = nodesSortFunction || defaultSortFunction; + } + + function loadNodeCtxFunction(functionBody, argNames, ...args) { + var nodeCtxFunction = null; + if (angular.isDefined(functionBody) && functionBody.length) { + try { + nodeCtxFunction = new Function(argNames, functionBody); + var res = nodeCtxFunction.apply(null, args); + if (angular.isUndefined(res)) { + nodeCtxFunction = null; + } + } catch (e) { + nodeCtxFunction = null; + } + } + return nodeCtxFunction; + } + + function enterFilterMode () { + vm.query.search = ''; + vm.ctx.hideTitlePanel = true; + $timeout(()=>{ + angular.element(vm.ctx.$container).find('.searchInput').focus(); + }) + } + + function exitFilterMode () { + vm.query.search = null; + updateSearchNodes(); + vm.ctx.hideTitlePanel = false; + } + + function searchCallback (searchText, node) { + var theNode = vm.nodesMap[node.id]; + if (theNode && theNode.data.searchText) { + return theNode.data.searchText.includes(searchText.toLowerCase()); + } + return false; + } + + function updateDatasources() { + vm.loadNodes = loadNodes; + } + + function updateSearchNodes() { + if (vm.query.search != null) { + vm.nodeEditCallbacks.search(vm.query.search); + } else { + vm.nodeEditCallbacks.clearSearch(); + } + } + + function onNodesInserted(nodes/*, parent*/) { + if (nodes) { + nodes.forEach((nodeId) => { + var task = vm.pendingUpdateNodeTasks[nodeId]; + if (task) { + task(); + delete vm.pendingUpdateNodeTasks[nodeId]; + } + }); + } + } + + function onNodeSelected(node, event) { + var nodeId; + if (!node) { + nodeId = -1; + } else { + nodeId = node.id; + } + if (nodeId !== -1) { + var selectedNode = vm.nodesMap[nodeId]; + if (selectedNode) { + var descriptors = vm.ctx.actionsApi.getActionDescriptors('nodeSelected'); + if (descriptors.length) { + var entity = selectedNode.data.nodeCtx.entity; + vm.ctx.actionsApi.handleWidgetAction(event, descriptors[0], entity.id, entity.name, { nodeCtx: selectedNode.data.nodeCtx }); + } + } + } + } + + function updateNodeData(subscriptionData) { + var affectedNodes = []; + if (subscriptionData) { + for (var i=0;i { + var node = vm.nodeEditCallbacks.getNode(nodeId); + if (node) { + updateNodeStyle(vm.nodesMap[nodeId]); + } else { + vm.pendingUpdateNodeTasks[nodeId] = () => { + updateNodeStyle(vm.nodesMap[nodeId]); + }; + } + }); + } + + function updateNodeStyle(node) { + var newText = prepareNodeText(node); + if (!angular.equals(node.text, newText)) { + node.text = newText; + vm.nodeEditCallbacks.updateNode(node.id, node.text); + } + var newDisabled = vm.nodeDisabledFunction(node.data.nodeCtx); + if (!angular.equals(node.state.disabled, newDisabled)) { + node.state.disabled = newDisabled; + if (node.state.disabled) { + vm.nodeEditCallbacks.disableNode(node.id); + } else { + vm.nodeEditCallbacks.enableNode(node.id); + } + } + var newHasChildren = vm.nodeHasChildrenFunction(node.data.nodeCtx); + if (!angular.equals(node.children, newHasChildren)) { + node.children = newHasChildren; + vm.nodeEditCallbacks.setNodeHasChildren(node.id, node.children); + } + } + + function prepareNodeText(node) { + var nodeIcon = prepareNodeIcon(node.data.nodeCtx); + var nodeText = vm.nodeTextFunction(node.data.nodeCtx); + node.data.searchText = nodeText ? nodeText.replace(/<[^>]+>/g, '').toLowerCase() : ""; + return nodeIcon + nodeText; + } + + function loadNodes(node, cb) { + if (node.id === '#') { + var tasks = []; + for (var i=0;i { + cb(prepareNodes(nodes)); + updateNodeData(vm.subscription.data); + }); + } else { + if (node.data && node.data.nodeCtx.entity && node.data.nodeCtx.entity.id && node.data.nodeCtx.entity.id.entityType !== 'function') { + var relationQuery = prepareNodeRelationQuery(node.data.nodeCtx); + entityRelationService.findByQuery(relationQuery, {ignoreErrors: true, ignoreLoading: true}).then( + (entityRelations) => { + var tasks = []; + for (var i=0;i { + cb(prepareNodes(nodes)); + }); + }, + (error) => { + var errorText = "Failed to get relations!"; + if (error && error.status === 400) { + errorText = "Invalid relations query returned by 'Node relations query function'! Please check widget configuration!"; + } + showError(errorText); + } + ); + } else { + cb([]); + } + } + } + + function showError(errorText) { + var toastParent = angular.element('.tb-entities-hierarchy', $element); + toast.showError(errorText, toastParent, 'bottom left'); + } + + function prepareNodes(nodes) { + nodes = nodes.filter((node) => node !== null); + nodes.sort((node1, node2) => vm.nodesSortFunction(node1.data.nodeCtx, node2.data.nodeCtx)); + return nodes; + } + + function datasourceToNode(datasource, parentNodeCtx) { + var deferred = $q.defer(); + resolveEntity(datasource).then( + (entity) => { + if (entity != null) { + var node = { + id: ++vm.nodeIdCounter + }; + vm.nodesMap[node.id] = node; + datasource.nodeId = node.id; + node.icon = false; + var nodeCtx = { + parentNodeCtx: parentNodeCtx, + entity: entity, + data: {} + }; + nodeCtx.level = parentNodeCtx ? parentNodeCtx.level + 1 : 1; + node.data = { + datasource: datasource, + nodeCtx: nodeCtx + }; + node.state = { + disabled: vm.nodeDisabledFunction(node.data.nodeCtx), + opened: vm.nodeOpenedFunction(node.data.nodeCtx) + }; + node.text = prepareNodeText(node); + node.children = vm.nodeHasChildrenFunction(node.data.nodeCtx); + deferred.resolve(node); + } else { + deferred.resolve(null); + } + } + ); + return deferred.promise; + } + + function entityIdToNode(entityType, entityId, parentDatasource, parentNodeCtx) { + var deferred = $q.defer(); + var datasource = { + dataKeys: parentDatasource.dataKeys, + type: types.datasourceType.entity, + entityType: entityType, + entityId: entityId + }; + datasourceToNode(datasource, parentNodeCtx).then( + (node) => { + if (node != null) { + var subscriptionOptions = { + type: types.widgetType.latest.value, + datasources: [datasource], + callbacks: { + onDataUpdated: (subscription) => { + updateNodeData(subscription.data); + } + } + }; + vm.ctx.subscriptionApi.createSubscription(subscriptionOptions, true).then( + (/*subscription*/) => { + deferred.resolve(node); + } + ); + } else { + deferred.resolve(node); + } + } + ); + return deferred.promise; + } + + function resolveEntity(datasource) { + var deferred = $q.defer(); + if (datasource.type === types.datasourceType.function) { + var entity = { + id: { + entityType: "function" + }, + name: datasource.name + } + deferred.resolve(entity); + } else { + entityService.getEntity(datasource.entityType, datasource.entityId, {ignoreLoading: true}).then( + (entity) => { + deferred.resolve(entity); + }, + () => { + deferred.resolve(null); + } + ); + } + return deferred.promise; + } + + + function prepareNodeRelationQuery(nodeCtx) { + var relationQuery = vm.nodeRelationQueryFunction(nodeCtx); + if (relationQuery && relationQuery === 'default') { + relationQuery = defaultNodeRelationQueryFunction(nodeCtx); + } + return relationQuery; + } + + function defaultNodeRelationQueryFunction(nodeCtx) { + var entity = nodeCtx.entity; + var query = { + parameters: { + rootId: entity.id.id, + rootType: entity.id.entityType, + direction: types.entitySearchDirection.from, + relationTypeGroup: "COMMON", + maxLevel: 1 + }, + filters: [ + { + relationType: "Contains", + entityTypes: [] + } + ] + }; + return query; + } + + function prepareNodeIcon(nodeCtx) { + var iconInfo = vm.nodeIconFunction(nodeCtx); + if (iconInfo && iconInfo === 'default') { + iconInfo = defaultNodeIconFunction(nodeCtx); + } + if (iconInfo && (iconInfo.iconUrl || iconInfo.materialIcon)) { + if (iconInfo.materialIcon) { + return materialIconHtml(iconInfo.materialIcon); + } else { + return iconUrlHtml(iconInfo.iconUrl); + } + } else { + return ""; + } + } + + function materialIconHtml(materialIcon) { + return ''+materialIcon+''; + } + + function iconUrlHtml(iconUrl) { + return '
     
    '; + } + + function defaultNodeIconFunction(nodeCtx) { + var materialIcon = 'insert_drive_file'; + var entity = nodeCtx.entity; + if (entity && entity.id && entity.id.entityType) { + switch (entity.id.entityType) { + case 'function': + materialIcon = 'functions'; + break; + case types.entityType.device: + materialIcon = 'devices_other'; + break; + case types.entityType.asset: + materialIcon = 'domain'; + break; + case types.entityType.tenant: + materialIcon = 'supervisor_account'; + break; + case types.entityType.customer: + materialIcon = 'supervisor_account'; + break; + case types.entityType.user: + materialIcon = 'account_circle'; + break; + case types.entityType.dashboard: + materialIcon = 'dashboards'; + break; + case types.entityType.alarm: + materialIcon = 'notifications_active'; + break; + case types.entityType.entityView: + materialIcon = 'view_quilt'; + break; + } + } + return { + materialIcon: materialIcon + }; + } + + function defaultNodeOpenedFunction(nodeCtx) { + return nodeCtx.level <= 4; + } + + function defaultSortFunction(nodeCtx1, nodeCtx2) { + var result = nodeCtx1.entity.id.entityType.localeCompare(nodeCtx2.entity.id.entityType); + if (result === 0) { + result = nodeCtx1.entity.name.localeCompare(nodeCtx2.entity.name); + } + return result; + } +} diff --git a/ui/src/app/widget/lib/entities-hierarchy-widget.scss b/ui/src/app/widget/lib/entities-hierarchy-widget.scss new file mode 100644 index 0000000000..d0f02a1392 --- /dev/null +++ b/ui/src/app/widget/lib/entities-hierarchy-widget.scss @@ -0,0 +1,110 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-has-timewindow { + .tb-entities-hierarchy { + md-toolbar { + min-height: 60px; + max-height: 60px; + } + } +} + +.tb-entities-hierarchy { + md-toolbar { + min-height: 39px; + max-height: 39px; + } + + .tb-entities-nav-tree-panel { + overflow-x: auto; + overflow-y: auto; + + .tb-nav-tree-container { + &.jstree-proton { + .jstree-anchor { + div.node-icon { + display: inline-block; + width: 22px; + height: 22px; + margin-right: 2px; + margin-bottom: 2px; + background-color: transparent; + background-repeat: no-repeat; + background-attachment: scroll; + background-position: center center; + background-size: 18px 18px; + } + + md-icon.node-icon { + width: 22px; + min-width: 22px; + height: 22px; + min-height: 22px; + margin-right: 2px; + margin-bottom: 2px; + color: inherit; + + &.material-icons { /* stylelint-disable-line selector-max-class */ + font-size: 18px; + line-height: 22px; + text-align: center; + } + } + + &.jstree-hovered:not(.jstree-clicked), + &.jstree-disabled { + div.node-icon { /* stylelint-disable-line selector-max-class */ + opacity: .5; + } + } + } + } + } + } +} + +@media (max-width: 768px) { + .tb-entities-hierarchy { + .tb-entities-nav-tree-panel { + .tb-nav-tree-container { + &.jstree-proton-responsive { + .jstree-anchor { + div.node-icon { + width: 40px; + height: 40px; + margin: 0; + background-size: 24px 24px; + } + + md-icon.node-icon { + width: 40px; + min-width: 40px; + height: 40px; + min-height: 40px; + margin: 0; + + &.material-icons { /* stylelint-disable-line selector-max-class */ + font-size: 24px; + line-height: 40px; + } + } + } + } + } + } + } +} diff --git a/ui/src/app/widget/lib/entities-hierarchy-widget.tpl.html b/ui/src/app/widget/lib/entities-hierarchy-widget.tpl.html new file mode 100644 index 0000000000..ab18748f0c --- /dev/null +++ b/ui/src/app/widget/lib/entities-hierarchy-widget.tpl.html @@ -0,0 +1,51 @@ + +
    +
    + +
    + + search + + {{'entity.search' | translate}} + + + + + + + + close + + {{ 'action.close' | translate }} + + +
    +
    +
    + +
    +
    +
    diff --git a/ui/src/app/widget/lib/google-map.js b/ui/src/app/widget/lib/google-map.js index ba1ae1f65e..a21f7a0876 100644 --- a/ui/src/app/widget/lib/google-map.js +++ b/ui/src/app/widget/lib/google-map.js @@ -338,15 +338,21 @@ export default class TbGoogleMap { locationSettings: settings, dsIndex: location.dsIndex }); - + let map = this; if (onClickListener) { google.maps.event.addListener(polygon, 'click', function (event) { - if (settings.displayTooltip) { + if (settings.displayTooltip ) { + if (settings.autocloseTooltip) { + map.tooltips.forEach((tooltip) => { + tooltip.popup.close(); + }); + } if (!polygon.anchor) { polygon.anchor = new google.maps.MVCObject(); } polygon.anchor.set("position", event.latLng); popup.open(this.map, polygon.anchor); + } onClickListener(); }); @@ -367,8 +373,8 @@ export default class TbGoogleMap { strokeColor: color, fillColor: color, strokeWeight: settings.polygonStrokeWeight - } - + }; + polygon.setOptions(options); } /* eslint-disable no-undef ,no-unused-vars*/ diff --git a/ui/src/app/widget/lib/map-widget2.js b/ui/src/app/widget/lib/map-widget2.js index 4762a3fda1..f330bd61bf 100644 --- a/ui/src/app/widget/lib/map-widget2.js +++ b/ui/src/app/widget/lib/map-widget2.js @@ -546,7 +546,13 @@ export default class TbMapWidgetV2 { function mapPolygonArray (rawArray) { let latLngArray = rawArray.map(function (el) { if (el.length === 2) { - return tbMap.map.createLatLng(el[0], el[1]); + if (!angular.isNumber(el[0]) && !angular.isNumber(el[1])) { + return el.map(function (subEl) { + return mapPolygonArray(subEl); + }) + } else { + return tbMap.map.createLatLng(el[0], el[1]); + } } else if (el.length > 2) { return mapPolygonArray(el); } else { diff --git a/ui/src/app/widget/lib/tencent-map.js b/ui/src/app/widget/lib/tencent-map.js index 6ce387e46a..9638667317 100644 --- a/ui/src/app/widget/lib/tencent-map.js +++ b/ui/src/app/widget/lib/tencent-map.js @@ -287,7 +287,7 @@ export default class TbTencentMap { popup.open(); popup.setPosition(marker); }); - this.tooltips.push({ + map.tooltips.push({ markerArgs: markerArgs, popup: popup, locationSettings: settings, @@ -353,6 +353,11 @@ export default class TbTencentMap { if (onClickListener) { qq.maps.event.addListener(polygon, 'click', function (event) { + if (settings.autocloseTooltip) { + map.tooltips.forEach((tooltip) => { + tooltip.popup.close(); + }); + } if (settings.displayTooltip) { popup.setMap(this.map); popup.setPosition(event.latLng); @@ -379,7 +384,7 @@ export default class TbTencentMap { fillColor: color, strokeWeight: settings.polygonStrokeWeight } - + polygon.setOptions(options); } /* eslint-disable no-undef ,no-unused-vars*/ diff --git a/ui/src/app/widget/lib/tripAnimation/trip-animation-widget.js b/ui/src/app/widget/lib/tripAnimation/trip-animation-widget.js new file mode 100644 index 0000000000..311efebf24 --- /dev/null +++ b/ui/src/app/widget/lib/tripAnimation/trip-animation-widget.js @@ -0,0 +1,700 @@ +/* + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import './trip-animation-widget.scss'; +import template from "./trip-animation-widget.tpl.html"; +import TbOpenStreetMap from '../openstreet-map'; +import L from 'leaflet'; +//import tinycolor from 'tinycolor2'; +import tinycolor from "tinycolor2"; +import {fillPatternWithActions, isNumber, padValue, processPattern} from "../widget-utils"; +//import {fillPatternWithActions, isNumber, padValue, processPattern, fillPattern} from "../widget-utils"; + +(function () { + // save these original methods before they are overwritten + var proto_initIcon = L.Marker.prototype._initIcon; + var proto_setPos = L.Marker.prototype._setPos; + + var oldIE = (L.DomUtil.TRANSFORM === 'msTransform'); + + L.Marker.addInitHook(function () { + var iconOptions = this.options.icon && this.options.icon.options; + var iconAnchor = iconOptions && this.options.icon.options.iconAnchor; + if (iconAnchor) { + iconAnchor = (iconAnchor[0] + 'px ' + iconAnchor[1] + 'px'); + } + this.options.rotationOrigin = this.options.rotationOrigin || iconAnchor || 'center bottom'; + this.options.rotationAngle = this.options.rotationAngle || 0; + + // Ensure marker keeps rotated during dragging + this.on('drag', function (e) { + e.target._applyRotation(); + }); + }); + + L.Marker.include({ + _initIcon: function () { + proto_initIcon.call(this); + }, + + _setPos: function (pos) { + proto_setPos.call(this, pos); + this._applyRotation(); + }, + + _applyRotation: function () { + if (this.options.rotationAngle) { + this._icon.style[L.DomUtil.TRANSFORM + 'Origin'] = this.options.rotationOrigin; + + if (oldIE) { + // for IE 9, use the 2D rotation + this._icon.style[L.DomUtil.TRANSFORM] = 'rotate(' + this.options.rotationAngle + 'deg)'; + } else { + // for modern browsers, prefer the 3D accelerated version + let rotation = ' rotateZ(' + this.options.rotationAngle + 'deg)'; + if (!this._icon.style[L.DomUtil.TRANSFORM].includes(rotation)) { + this._icon.style[L.DomUtil.TRANSFORM] += rotation; + } + } + } + }, + + setRotationAngle: function (angle) { + this.options.rotationAngle = angle; + this.update(); + return this; + }, + + setRotationOrigin: function (origin) { + this.options.rotationOrigin = origin; + this.update(); + return this; + } + }); +})(); + + +export default angular.module('thingsboard.widgets.tripAnimation', []) + .directive('tripAnimation', tripAnimationWidget) + .filter('tripAnimation', function ($filter) { + return function (label) { + label = label.toString(); + + let translateSelector = "widgets.tripAnimation." + label; + let translation = $filter('translate')(translateSelector); + + if (translation !== translateSelector) { + return translation; + } + + return label; + } + }) + .name; + + +/*@ngInject*/ +function tripAnimationWidget() { + return { + restrict: "E", + scope: true, + bindToController: { + ctx: '=', + self: '=' + }, + controller: tripAnimationController, + controllerAs: 'vm', + templateUrl: template + }; +} + +/*@ngInject*/ +function tripAnimationController($document, $scope, $http, $timeout, $filter) { + let vm = this; + //const varsRegex = /\$\{([^\}]*)\}/g; + //let icon; + + vm.initBounds = true; + + vm.markers = []; + vm.index = 0; + vm.dsIndex = 0; + vm.isPlaying = false; + vm.minTime = 0; + vm.maxTime = 0; + vm.isPLaying = false; + vm.trackingLine = { + "type": "FeatureCollection", + features: [] + }; + vm.speeds = [1, 5, 10, 25]; + vm.speed = 1; + vm.trips = []; + vm.activeTripIndex = 0; + + vm.showHideTooltip = showHideTooltip; + vm.recalculateTrips = recalculateTrips; + + $scope.$watch('vm.ctx', function () { + if (vm.ctx) { + vm.utils = vm.ctx.$scope.$injector.get('utils'); + vm.settings = vm.ctx.settings; + vm.widgetConfig = vm.ctx.widgetConfig; + vm.data = vm.ctx.data; + vm.datasources = vm.ctx.datasources; + configureStaticSettings(); + initialize(); + initializeCallbacks(); + } + }); + + + function initializeCallbacks() { + vm.self.onDataUpdated = function () { + createUpdatePath(); + }; + + vm.self.onResize = function () { + resize(); + }; + + vm.self.typeParameters = function () { + return { + maxDatasources: 1, // Maximum allowed datasources for this widget, -1 - unlimited + maxDataKeys: -1 //Maximum allowed data keys for this widget, -1 - unlimited + } + }; + return true; + } + + + function resize() { + if (vm.map) { + vm.map.invalidateSize(); + } + } + + function initCallback() { + //createUpdatePath(); + //resize(); + } + + vm.playMove = function (play) { + if (play && vm.isPLaying) return; + if (play || vm.isPLaying) vm.isPLaying = true; + if (vm.isPLaying) { + if (vm.index + 1 > vm.maxTime) return; + vm.index++; + vm.trips.forEach(function (trip) { + moveMarker(trip); + }); + vm.timeout = $timeout(function () { + vm.playMove(); + }, 1000 / vm.speed) + } + }; + + + vm.stopPlay = function () { + vm.isPLaying = false; + $timeout.cancel(vm.timeout); + }; + + function recalculateTrips() { + vm.trips.forEach(function (value) { + moveMarker(value); + }) + } + + function findAngle(lat1, lng1, lat2, lng2) { + let angle = Math.atan2(0, 0) - Math.atan2(lat2 - lat1, lng2 - lng1); + angle = angle * 180 / Math.PI; + return parseInt(angle.toFixed(2)); + } + + function initialize() { + $scope.currentDate = $filter('date')(0, "yyyy.MM.dd HH:mm:ss"); + + vm.self.actionSources = [vm.searchAction]; + vm.endpoint = vm.ctx.settings.endpointUrl; + $scope.title = vm.ctx.widgetConfig.title; + vm.utils = vm.self.ctx.$scope.$injector.get('utils'); + + vm.showTimestamp = vm.settings.showTimestamp !== false; + vm.ctx.$element = angular.element("#heat-map", vm.ctx.$container); + vm.defaultZoomLevel = 2; + if (vm.ctx.settings.defaultZoomLevel) { + if (vm.ctx.settings.defaultZoomLevel > 0 && vm.ctx.settings.defaultZoomLevel < 21) { + vm.defaultZoomLevel = Math.floor(vm.ctx.settings.defaultZoomLevel); + } + } + vm.dontFitMapBounds = vm.ctx.settings.fitMapBounds === false; + vm.map = new TbOpenStreetMap(vm.ctx.$element, vm.utils, initCallback, vm.defaultZoomLevel, vm.dontFitMapBounds, null, vm.staticSettings.mapProvider); + vm.map.bounds = vm.map.createBounds(); + vm.map.invalidateSize(true); + vm.map.bounds = vm.map.createBounds(); + + vm.tooltipActionsMap = {}; + var descriptors = vm.ctx.actionsApi.getActionDescriptors('tooltipAction'); + descriptors.forEach(function (descriptor) { + if (descriptor) vm.tooltipActionsMap[descriptor.name] = descriptor; + }); + } + + function configureStaticSettings() { + let staticSettings = {}; + vm.staticSettings = staticSettings; + //Calculate General Settings + staticSettings.mapProvider = vm.ctx.settings.mapProvider || "OpenStreetMap.Mapnik"; + staticSettings.latKeyName = vm.ctx.settings.latKeyName || "latitude"; + staticSettings.lngKeyName = vm.ctx.settings.lngKeyName || "longitude"; + staticSettings.rotationAngle = vm.ctx.settings.rotationAngle || 0; + staticSettings.displayTooltip = vm.ctx.settings.showTooltip || false; + staticSettings.defaultZoomLevel = vm.ctx.settings.defaultZoomLevel || true; + staticSettings.showTooltip = false; + staticSettings.label = vm.ctx.settings.label || "${entityName}"; + staticSettings.useLabelFunction = vm.ctx.settings.useLabelFunction || false; + staticSettings.showLabel = vm.ctx.settings.showLabel || false; + staticSettings.useTooltipFunction = vm.ctx.settings.useTooltipFunction || false; + staticSettings.tooltipPattern = vm.ctx.settings.tooltipPattern || "${entityName}

    Latitude: ${latitude:7}
    Longitude: ${longitude:7}
    Start Time: ${maxTime}
    End Time: ${minTime}"; + staticSettings.tooltipOpacity = vm.ctx.settings.tooltipOpacity || 1; + staticSettings.tooltipColor = vm.ctx.settings.tooltipColor ? tinycolor(vm.ctx.settings.tooltipColor).toHexString() : "#ffffff"; + staticSettings.tooltipFontColor = vm.ctx.settings.tooltipFontColor ? tinycolor(vm.ctx.settings.tooltipFontColor).toHexString() : "#000000"; + staticSettings.pathColor = vm.ctx.settings.color ? tinycolor(vm.ctx.settings.color).toHexString() : "#ff6300"; + staticSettings.pathWeight = vm.ctx.settings.strokeWeight || 1; + staticSettings.pathOpacity = vm.ctx.settings.strokeOpacity || 1; + staticSettings.usePathColorFunction = vm.ctx.settings.useColorFunction || false; + staticSettings.showPoints = vm.ctx.settings.showPoints || false; + staticSettings.pointSize = vm.ctx.settings.pointSize || 1; + staticSettings.markerImageSize = vm.ctx.settings.markerImageSize || 20; + staticSettings.useMarkerImageFunction = vm.ctx.settings.useMarkerImageFunction || false; + staticSettings.pointColor = vm.ctx.settings.pointColor ? tinycolor(vm.ctx.settings.pointColor).toHexString() : "#ff6300"; + staticSettings.markerImages = vm.ctx.settings.markerImages || []; + staticSettings.icon = L.icon({ + iconUrl: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAKoAAACqCAYAAAA9dtSCAAAAhnpUWHRSYXcgcHJvZmlsZSB0eXBlIGV4aWYAAHjadY7LDcAwCEPvTNERCBA+40RVI3WDjl+iNMqp7wCWBZbheu4Ox6AggVRzDVVMJCSopXCcMGIhLGPnnHybSyraNjBNoeGGsg/l8xeV1bWbmGnVU0/KdLqY2HPmH4xUHDVih7S2Gv34q8ULVzos2Vmq5r4AAAoGaVRYdFhNTDpjb20uYWRvYmUueG1wAAAAAAA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/Pgo8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJYTVAgQ29yZSA0LjQuMC1FeGl2MiI+CiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPgogIDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiCiAgICB4bWxuczpleGlmPSJodHRwOi8vbnMuYWRvYmUuY29tL2V4aWYvMS4wLyIKICAgIHhtbG5zOnRpZmY9Imh0dHA6Ly9ucy5hZG9iZS5jb20vdGlmZi8xLjAvIgogICBleGlmOlBpeGVsWERpbWVuc2lvbj0iMTcwIgogICBleGlmOlBpeGVsWURpbWVuc2lvbj0iMTcwIgogICB0aWZmOkltYWdlV2lkdGg9IjE3MCIKICAgdGlmZjpJbWFnZUhlaWdodD0iMTcwIgogICB0aWZmOk9yaWVudGF0aW9uPSIxIi8+CiA8L3JkZjpSREY+CjwveDp4bXBtZXRhPgogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIAogICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgCiAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAKICAgICAgICAgICAgICAgICAgICAgICAgICAgCjw/eHBhY2tldCBlbmQ9InciPz7hlLlNAAAABHNCSVQICAgIfAhkiAAAIABJREFUeNrtnXmcXFWZ97/Pubeq1+wJSQghhHQ2FUFlFVdUlEXAAVFBQZh3cAy4ESGbCBHIQiTKxHUW4FVwGcAFFMRhkWEZfZkBZJQkTRASIGHJQpbequ49z/vHre7cqrpVXd1da3edz6c+XX1rv+d3f+f3/M5zniPUW87WulAnTnN5i8IBYpnsttKoSZoVpitMFssoIIbSqIILxFIvVVGsQjdCQoSEKvtweMkIrwh0+gm6fMtWA7s2XicP1s92/ib1U7C/TfmCThrXyvm+5TgnzgwRWtUyDksL0GJigIJq8LevaWFnWMz+YzYJCHsRusSwHej2etiA8HT7Klld7406UAFou1yPdVyOclxmWZ8TRDnMaQT1AQtqU4AsBIxFOPsigAnAbBzwulAV/kdiPGK7+RuWJzaukUfrQB0hbfYSXRlz+TDKAWo50LiIpoBZEjAOoWckBVybRMXwCg6bvSSPta+UhXWgDrM293I912niSJvgTLeJ6eoFrJk1fNdAT4mAOCAueF28JA6/tsp/bbxWbq0DtUbb/EV6tTRwuvWY5bg0q59izeHScSYArfXpFHjB8/hd++rhy7TDCqjzlupFOJxshNNFQL0aZM7BNAPGDS5Ea7kf5ZcbVsh360CtsjZniV7qOFyIMscYYtYbwdGxC+qhEuOvtosbN6yRb9WBWsE26/M6PT6Bi9VykRNnnCaH19BeDGlg4uD3sAfhRr+Lm9vXyp/rQC1ngLRElxuHCwSmo3WA5u3glO0FvOz73LZxpXylDtTSa9ArUL7gNDDJJkaA9ixyT5sY2CR71GXdhm/I1+pALXKbvUSPcR1+KMrhUGfQoQZeKZZ9xvNY3r5C/r0O1CG2Q7+ih8UbucaJc5r6qVmjeiuuhk3wB7+Hq9u/KQ/UgTqYSH6xLnDiLBHlIPXqwCoZABwAdvvKDzdeK4vqQC00ml+o74g3scqJ8UG/p65Dy6pffdYrfG3DNfKLOlDzB0vnAd82hnH1Yb4CYAgmDfZ6Pt97dpUsrgM1CqSL9U4nzketV2fRSiPCaQCb5DGviy+3Xy+P14EKzPyqvrWxkR8JHF5n0erSrtbnDQsXtK+UX1WBWVFBFl2klzY28l91kFZfUx/EMNZ1+eX8ZfqvI5ZR5y3V60S5VAxO3RetbikgDvge921cKR8aMUCdgTY2f50bjeFT9dml2mmp7KxNPQk+8dwaeWJYA3XM+Tp22sHcCby77o3WpiuA8LyX5JL2lXL3sATqmy/Vw/0mfua4zLOJeqfXcpClSg8+X16/Sn4wrIA6c6HOaGjgYWOYXg+ahgFYDSh0+B5fb18ta4dF1D97iR7TGOePxqmDdNg4AhZEaXHjXD9vsZZl2rWkjDpzoc5ojPNHcZhSB+nwdASApK98vX2lrKpJRp19mR7REOcRcesgHb7UCkDMjbFy9mJdWHNAPfgcHefG+Llx6plPIwGs6oFruHruIv1C7QD1Q9rScih3mRhz6kw6csAqQpOJsXbOUj27JoA67zhuEuH4ugU1IgMs13G5fvYifU9VA3XuIr3ecfh4fbgf0WA9yI1zc9UCde5Svcw4fNl21zusigKdsk9RqwcCM+cv0UeKbzAMNcL/qh7jNvKQKA31BJMKAbIiPZ/n7V2wPndsWCFnVQmjaizWxL8IdZCWDZjhWz9PK+h9SsSsjsuZcxbr/6kKRp23VO82DifVdWllmFMHwaoiZWJYAYR9fpIz2q+T+yvGqLOX6medWB2kZWHQ8CHdf2Mgt4zXD4yCB/fdBVqdGEMu2DZooM5ZrEe5wlqbrGOpHABNA2cuCdAPQCPfs1iaN48TYGLMnb9kaKsEBg1Ux+VbIoyrJz6XB6D5gKi6v5R7rltOAOdi1yI22wMI581dop8vK1DnLtbLnBjH12eeShAgZQK0AHAWMuynsWeEdaUlJhwRYo7DFWUD6vzL9VjjstDvqeOrlPozckhnP0D7HrMFANWmAzYnWEssAcQwdd5SvbUsQCXGchEm14f8EutPMtgwDNDe4zbErDbi/xyyoY9doy6IEjabBGM4c95i/VRJgTp7kZ5iYpxYj/KLBFAigGQzwJl53EaDtk9v2pAzpMGNXolgye2fankkANDAICRA4e7ZRRqbP4mnBebVjf1BADTz3xxDsBId7IjsB6HmYUBV6PRhaweQTPVwHKa3QJOb8lBDtz5PVUJokBAwSuCvOo2Q6GJp+ypZWXRGnT+eq0XqIC16gNQLRiJYLxQsaYhpsxjWBgUjXu+EriScMEO5/0seO7/Xw+vfSfDgFz2Omqbs7AEbEWSRJ8gqRfN7wHG5+NBFeljRGXX+Mt2DMqquTQfBoLkAEAaepMAXAm7acBwGV+gxa2FnAt7ohCXvt5zxLp+j59v9gVaIjq7+qcsNDztMaKBvw7U+Zs1g1TSmLYULEGyK8eP1K+W8ogF13lL9thPjS/Uc00GAMwdb5Quc0t5Dyd5/VQOAvtQF05vg9MMtXz7d46DJGoDPy9HTDpx7fZyHXhBaYukyQDKH/hIDtfdCSexmzKZ1smfIQJ37FT3MaeVe9ZlaZ9Mh6E+JAGcOzam5hmcLnoUXOgKWXHuKz6nv9Jl9UIpm+/O1XfjLc4bDvhFj7vhU75t04JQNqClWxfLrZ66VM4asUZ1WLhTqIC04gs8VJNmQtrTpGjOsR9M2De7do9VCTwJe7IAJMeWGM3xev6GHr5ztMXuaBgxayOSLD9MnW2jcL4V7v59IDm1awn5XHxTe/+ZF+lH6v8b6eTPL57UO0jRmzDmjk3G/71+bgzEz9ahNj/pVIeHDi7vgiCnK6jN9Pni0z6RxClaih/j+WEygwQn9ltDvCGdV9T0spT2fxmG0H+PTwF2DBurcpbrKuDTUtWkejRkB0KzhPAKgaWC12c/fm4BOD46aqPzgfJ/3HuHT0pwCvTdI9BjYuVvo2QNmUuozK1wh13pgXM4GPjGooX/Ml3SsMZw9orOj+kuRy9SSmWZ8hknfZ0PZ0FAf2oLd+tDZA5tehaMPVH55scc9KxKc/E6floZUkDQUe1DgX37vMra1/xG9nPgVgbmL9ZZBAXVKMwtQZo5YbZprDr6QKD4jcaRXo6pNacmI+zu6oCsBRx6kPLkyyS++nuADR/nEnRRAh9oPDjzzvPCTxw0T4tV1qm2wGuB98y/Vtw146BfhNJERVr40XwRfqMWUaStFBVSpm29hdwJ27oUFx1s++xGfo+bZvsAHr7i/7aZ7Xd5IwiQ3B4NKRc/7NJo5GXiycKCepA1uA8f43SMYpPkAGiELMhlVyW0x+Qovd8DsFjj5cMuiT3ocNCmPBzrU5sJT7YZvPmRomxj6nqHxVCWVF2Aqev4vAK4tGKhzjuDGEbGVeCEeaC6AQpYRr1HmfeiW9GFzF8QsXPNhnzNP8Jk1zQYo8YeoP/OJTYVrfuYydUzEHL5UAaOmRhuniVkzLtW3bV4rT/YL1IkX6VTX5ehhnRSdI5ljwBF85nNs9tAvCj0ebOmEd05SLn6v5eIzPJqbU8/xSowOB+58xOHeF4QpzaQnn0Qwa0W7JQmNjVwJnNEvUCeM4yz1aBsR4ByIBo2ymMiO+sPP7/HhxT3QNhZuOsfntHf5jB+txdefedjU82D1r1zGxDIAGpo2lYgovOyhP4Hr4bgc3vYFnbVpnTyXF6gmzvtNKhIbUUN8Ln80l0kf9kAJ2VHAvgS8koCTpiurzrJ88gMexqQeL+d5deDWe1we2wazx6a+s4kAYoWAGdVP6nNIbBR/B6zJC1T1ONXKCARoLv0p0UN6VnRvoSMJr+yAjx6mXPABn1OO84nHGbr/OZhmYMdu4Ya7DYeMCmRweIiXzGE/pWWlwjLAGLDKu/ICdc4iPcltJFbz0X4hETwR0XkOiykzMNKMpJLXuiAmcNocy4Vf9HnXYX5AW0Uf4m3hgtLALb9z2LBHmNYaETBJP4xaIbKyQWx0Wl6NKvC5mh3yhxIghSLPXGDNnPK0Fnb1wK7XYeHJljOO93nX4ak3KHqA1GvAFvi+Dry2Q/jyXQ4zR2Xo0ah0vioKqNBgBcCcJXpyeIugNKA6DcytufVQ+QDaX4AEheeBhkz75zpgZhP8wzGWBad5zDgw9WDRz53N+IEFokmUq2+NMTYGTig5Oi0puj9GrWCzHhjDRUA2UOdermepz4HDRn/2asvQWqO0/id/Hmjmfd8GFpPtgG9+3Ocjx1jePDMVSZUcoAMAqQtPPevwyz8bxjdGsKeka1IM6V5qFQBWfTAucyN/vWniMOMwuurnTAvNA7URw7nNON6bWmez80B7n5v04Lm9MDEOaz7qs/OmHhZ+0uPNh9jC80AL/mF+6qbZPlMh6EldlNff4dKtQWACQYAUNfSH0/qkygJo9Tho9mX7l1X3MaomOUqdGg+QNOOp/eWBRllMqeNJH7bshrGNcOunPd7zdstBk+yg80D7v/I0PwIL1KaPP2O45XGhbXIGADOCqKzhv0rYtPeUSIzWuOHgLKAKvBO/RgHajwYtKA80BerOBLyWgBOmKDec63PysSmLaSh5oDl/WKGeVWFsmkjAFTe7HDSB/ctMQktMcrGmSHX2u6+8F1idBlSniTF+Z40yaH9ZTOTIaArJgG4PXt4Fxx6s/PhTHke/2dLaTAkspoFW0C1Qmzpw76MOj74qTGkJsabJZtaqZtPwaGh5exqjzlusl1RNtD8UBu0HoH0gDUmCnSnP+H2HKEsu9ThyfonS7AbEoAPXpns74Iu3ukxozLG6NAc4RaoQpCnycJuZnHa5qnJkxQtLDCaTPqoMTub9zIVyPvg+vNEFm16Bk2db7v5qktu/lghA6lGcROU0ahhsalShbKrccp/LC3sJEq0zI/ywNiU3aKuu+dB2uZ7Vx6hOA2+pmD4daKrdQBk0dN8qbEnlgZ5yhGXhxz0OmZpKsyv6PLwdItoLD6Ce32ZY9zuHmaPJLtcTWvosUal9VC9g1YIjnADc7o4/V0cDLRVZadpfRZFCE0U0O2rvS7MDEh5s7YZkJyw/2XL2CR7zZqQAWvQAqVg1xk3BeP75Ay7rO6CtdT8wpR9zX2ogn0N9MHHeCuCOO5B3qs8BVQ/SXACNiu5TN8+HF/bB8ZODPNAFZ/i0NGsJ8kCLvcVI4Wy6eZuw5HbDrCnsz4yKAKfkGuqrGLAaJMmMAnAdw0QsLdUG0pzLPfph0L480L2BsLn5HJ8Tj/GZOlFLMLyXag+cwtn0+p+5jB8XXZVPckX2NZQdpz4T5izUo1wnxlRjaCjbsuhCQNrfco8IDSopD/TlLjjlEGXFmZZPn+jtl4sVj+CLDFIXHnzCcMvTqalSs7/omeRi0xrRphmnehQuB7omRpwKRfwDAmnofuaxTg+27YB3HKx873yP971NGd2i0bORVcmgg6E7Zd2dLrEQQMPaU2rMjspzukepMNm1SeaUTVhr4SDNXNaRWSdUCNbCJy2cNVf57AKP9xzhBz1QFR5oadn0rkddfvlXoW0S6eZ+ZqHeGhzu085IHIkrY1yxTCt3LmLO3TkiQBoubqsEeaB7ErD9NTjvnZYFp/q8Y57FdSlhHmg5mhT8tI4u+KffOEwfF/3SnKxZi2ANCKrBtTDKKBVCKv3P0YfK4mzphMkNcMHbLQvO8DikNw+0qAxa6uF96JH+bQ+43LdFaBtN3jS+rOK8NQjY1MTNdFdgTCWCqCytmiOC9yxs74K9b8DXTrF8+oM+cw8uZx5ouUBa2FRpMgGX/bvDjJZU0Yio1L2It6opbZqBGwvTXFUmVpDSs4EcKvm9aR8cNU656F2Wi0/zGdVcSxZTCbSpAyt/FGO7hbEZEb5kAFpqZZq0kLOjtLrAmHL2keZi01Amfo8Hm/fC9//O49TjKpkHWl0g/dvLws//ZDikaT+bZpFyPjat0abQ6EoBxXxLyqQZx5MWxjTA35YmmDlVK5wHWl1B1Lf/3WVzB0xpyWPw51MStRpMBTsPVc2XQYDnd8FDlyaZOdVW+TRnee2o514S1v3BcOiUbOBlDvsow2LID3VdY0WB2hfpp4b9Tg8+dZhyzJss+FJEgFbr5liFr4O69Icxpo7P/Qsj1z9JDQdRGZeqW94LI6KfQlOg3RbmTbU4Eiyqq80IvrhDvuvAfY8bHnhOmNQcKg2Zq2iEpvQrGfdrlUwDfLhuNX0pA3T7sGNfEPXXd2KBmAvPbRMSNg+2q6DCSckptdwcErnVdmoPpgYDL7welLl2JFUvaUiwr0ZdmnN8yckoJx/v85M/OLywT4i7RFt7IW06nLAqAQ48U+kv0XuiRaDZhZ/+1fDU84bGeLEuDRN4O1VTs2ZgYLUKY5ph6bkeL+6KYE4NFTzrHe5DBDBMQOtVR+/J/m47ZBR8/TaX13ZDU0MxPUCpQsAWxqrdCTh6nuXEucreRIaJkUv7Z4K2lmWU0G20vBU7c2eap05w3IFtHcJblzdwx8MO+7qFxpIBtgoKgg5AAnztk14wFObYoSVqNUQaRrUmQQrQZYDdlewvidg7vsmBqY3wj7e5/P0/xbjlPgcxSlPD/jI1xZMFlWbYwqwzz4fDZvmcNM+yJ5ED5/1tE1mjOkCg24iwvSJTbPlmTySoQjejBTbvES75lcsBFzfyvbtcXtkZMKxTVMBWmmELozrPE5adm2R7935WTUviyShhhA7qY6oRqfucie++6jMiTCv5j5ACgioy7EEBIzAhDuMa4M71hgefMOzYYXjTDGVsaxBsFGcFbSWzOAorUKoKo5sh3i38/lnD6Fjwst5zJVFZ/qGTWovmvwT7tv7BGNhb9i8e5ftF/BX2E50xML0Vunzh+48Y2i6Js+ymGO0vGmKxwG8sriRwytyjhUmApA+fOdHnTeMUL1RsI22TYO0nkKohZpWg7190Jr73qg+IcHg5Zxmj9jqSHI+Hk38l9X+zC+Nb4bEtwq8fd3h5i2H6ZGXaxFAZypLqk1K1wlh1bCvYhPCzpw3j4xmsmqH9s5Kna4xVJSCg3xkTo71sGwzkmI8mPHT1nmCTflzCx1O3A5qh0YVfPmM4bmmc89bE+X/PGHylSD5suRm2MLbo7oHPnOhx+ISguFvmVpbkWFZei80m0ESC3cYmSVTqR+QFK+kFaMMgFbP/vnFgVAO0TQ0Y9uwfxPjSd2Lc96RDU6MScwOdWzzGK6UXWxiiep9x+WkeryTS91jVPHsU5DtetdaUYa8orxo/yTabpKci+rQAZs1i1NCKSwkvFTYwuhEmN8FjLxrO+o7LsYsauPuPgRfb3FgrXmzhdtWJR/ucMcvSldoeKLy9eppWzbgGaspXFfbisdVY5XUMHeXdoL0wsPYBtpdJQ/9LmGGdEHgdaIrD7Amwr0f4zI9d/uGGGDff62IMNMRrwYstgFVT+RALTvPZuivipREbZQyAtKsp6t/Rfr08buzz/FEcXqsIrecCa56s9TQ5EAoacNLBqwYaYtA2Cp7dJXzxDodJX2jgxrtdXn9DaKpqL7YwVk0k4W1tlgXvsWzvZH+Jd0vezYVrxQGQIBd3L4D5222yG+iomOkfBdYMwPaB02QcM+mPp/0f0rFxF9pGB1vuLLzT4fxvxbjuZzH2dAtNcXCcUjCslJxVIaj1es77fcbEU6mRNkKrRthTWgOsKgasx9N9fojfw18qNpMo2W6A5NhdTsgB2FygDoEVBxwX2sbA9h5h3SOGWV+Kc+WPYjz3shCPQ6yqAFu4Vj1ituWcYyzbutLBmetv1qVQpaAVB/B5oI/T5i3WS5wG1tlEFXy7ge5+EqHDBlLY9/VOOKAJ3jPLcvHpHrOnKUkv2FequKwzmExwKUj/GgN7O2HW4gZmNgcXZNaoE958ArLyK6rRV3Wa4S9XSN+8DxtWyXekWrbuiWBYovRrmGEzCCxNBmR4r2nWloHJqUIO92wwHLkszvnXx3ny2QAcDbFi9t9grK3CgG0tjBsF3z/T47WuCLtKcwdS1cqqYsDr4tXw2QuGkG72VNX674xSNJGSIGO4TwNtoYB1Ai+2tRHaJsGjm4VPfC/GBWvj/GmDwXUpshc70LzYwiXAh4/2ees4xfOJLjqXY6q1KlvQb09kARXhUapxQ7QBaNi0gCyDYbPA64Qsr9BzxzQGCTD/+6rwkTUxPnRFnAeeMHT0CM0VyYstnFUnjoavfMxn8xukZVRpf/mq1TgJIOA4PJQFVHF5vKorahQC2DDDEu27Skin9bkDmY85wbA/eyK81il89qYY562JccfDDr4GLoJTVi+2MFbtSsB73+rzoVlKRyKHl1oLCSsC6rPPT7AlC6i2h7+oz56qT1bIBdh8myqEY5IMSZCpW8OTB2qCnIEpLbBln3DBj1wOvbSBnz/osG2n0NqkRZ48yMWwBU6tarC8euEZHj02VL7TZgeVmcFptdlV4vLShlXy0yygblwlt+GwlVppmYAlArDh4Z50cGZpWzIAm+HHxlxoGwtTGuHLd7icc32ctbfH2NspxGMBQErLsAWmAXpw9HzLBzNZlYj7uRi0wqAVB2yS9kzJut88TrBRHGqr5QMsRG5WK1FgzvRmnQgv1gQlO6a3QoeFlfc5zPpinHW/cNmwxdDSVGzAOqRnbRWuV5d+MskrIQdAbcQkQD4rsJJxlAt+kn9Js6rC/0x691X7nDjnVM12k0UAbL+TBxkyIW1yLCoZJvV8IzA6DuOb4Z52w73/47DlJcOMKcqksYH1ZW2xf1xhRaWsBnaV2Sc8/LyhOR4aMcgIOsnI/620r5oqYbRxlZybeQbS2rylmhCI1XyVEo3WcFmP5UmLy9pHgBz+pA1AuTcJr++Az73PctqxPse+xWIUepLl//mOA6/uEs5aHadHg0kACedEZASa4dTK3OgoD5tay13rr5XTcg79qS93t3Go/Sa5Ay+J2pQhc8MGMnIIInRreLrWuIG11XYg3Pa04bx/jnHxt2M8/BdDQzzQuOV0VXwfDj5A+ex7fJ7vzNakCumrVntr1FaYoGwgUR6NslUzWed+6zO8Wq5tbCJuWfkEmZWdw5o2c/Ig9fzxTTC2Af5zs+G0tTHOXhXnnj85eJYgL7ZMP7srtRJg3mhI+BEGQrVZU8F53ZzYxy+yRojMA+OOuGprrJnTVRkPwxuw+QKvKI2btcqT7H1HwytCY06gYbfuFm59yvDE0w7WE950iO0LukrNYA0xmNgIv/hfQ2ssu5R62grWCq+tMg5Yy8PPflO+2y+jblonL3k9/Lc4DN8mEfjNMXkQmcQdISH6ZrucdJsLA80NcEgLbNol/ONPHY5b2sAt/+GyuyNYeWBKCAbPh1OO8zl2qtLjkb9oRaVtqTgkLcsL6LJUu1Ib32Lo8rsZGW2ogVfETI+GCkJkmu0JD7Z0wzvGKae+zXLhSR4tTfRtNFwKVn3sGcMp62K0jUvX1mlBVYi6ys2oYkCV59evkEMj2TbyVcul2+vh8WHNqgPVsOQJvDLzYMnOiQ0zbDwWbFm+o1tY9aDD/MsauOF2l82vCs2NWsTp2aD1JOGYeZbzjwiVA8oo/yNK7pJA5dKnws05ZUHO1/ncqSOtkG7E5raRU7S5Aq+MGbC0mS+TvfLAjcH0liDV8Nv/6fCptXEW/iDO1p2C4xSzqEbQLviQT4MTrYuVyoIUYWuPz29z2m25Hph4xFV/Nc2cjTKWkdhyMSwMaPIgHLykJSyHcg+MQGsMPIU/bxPW/s5hx6uGyWNg2qSAYYc6eWAVZkyxbNlqeHKb9O3ekFWkrgIJ1RID3+O3z66SdQV2R3qbt0zXOC5frYrM/2rVsGTr1SwdG7F+KefKA4KJht0JcAXecaCy8GMebzrEEneDufzBjnSuA8+/Ihy1Is7c8Snr1EQDNW0lQBmA+sw3MCkBMrChH2DDtXKZeiSGa134oWrYnDkFOVyEyERuZ78sCN8f2wgtMXhqm3DCiiCR+8EngjTDwRY49i1MHa9MHg1+lWzxY1xQy+35QNovUFMn+ofGod4iNGx/1lbm6lgyVsmmadrwzFcqD6WvRsEkeOoV4e9vdvnEtXF+80eHhCeDyotVpLDXaHnOp1X2+Elu6RfQ/T0h0clNVnl1xLNqPyybL5Eb6T8vVjKcg8y82JY4TGgK8mLP/VeXj14T4/b/dNi1D1qbtSAv1jGwfTds3R0Ur0DyXIBSesCmSko+1L5afj1koG5aK09iud3E6tgcEGBzDf1EMGzGsu++93Ci82Jnj4Od3cLCO1w+fl2cdb+Ksacz0KD50gwb4sq/3e/2LWgMa9C+DStkIFHM0FvHTs4fRGybu81fpvtQWup7Pw1s2MyqsicEEwG9mxSHvctQtpYSHXhpaheU3mO+wvNd0OrCkvf7fPhIn/kHK109gSbtZdLGuHLrH1yu+I3L2DjpeQu56n+VEKgSZEn9dMO1ck5BgWDh/gbfFYfL1a/jsKDLP7S/a3jXl75xTFNACWcthf6XEEh7mVY1dbz3mAbx1+yWALCrHnC48TGHk95s+cz7fQ4cb1GE7Xvgxvtj/PzPhjHxDCAWEDiW6Pxs007WDtItzN0OvUjHxCfx3wba1NaxOBR7K9/CuvC28FkMG7K7sgz61H2rsMeD1ztgythAi768ByY3kw7SKL80qrByCUDrNEJPJ8ufXS1XFR2oAPMW65lOI7fbnjr2hgrWNMBGgC6rAG+uWlI58kjDOO/dBVEiAieRfqy1UgRQlk3PrJDZA7KxBvLkDavkDj/B/eLWcVcUayvPCtqo6dlwjYK0fIPQe4Xf06T2PtAcn5MXpKW7YBOe8o2BvmzA6Q++shxle92uKqJTQP/5BGnLu6McA0NkGXnppzByOSL7PrDFwCp3ta+UHw/mlA24zVmsV8Sb+MaISQOslIbtTxbkkRW5ejmytGeux4p5bQbM/tr6a2TyoEA+mBe1r5KrveQISgMsN8NGrHoNPzdrZYGJZuPMYV4KMfhLdS0qvk2yYtBsPNgX2i4uVa0OvmekAAAFN0lEQVTs9pTDHbBpOjIHGDOH/MxJg3xpijlBWuQ+NQ2A8JONq+WGoZyaQbf5y3SBcfnuiM+uqrQsKDThOV+F71IO+coL61fIzCGBfSgvXn+tfM9P8EDdBSgxyxItC6Sf+XrILQHKAVICW6zTT3LxkFl5qG+wewefUFhf16tlkAQRQIoEbmjZS+SK2gIkR7GifJRl7Wvk7iJfr4Nrcy7XD7hxfq8WU88FqKw0KF+v9/MRLqjH3etXyilFAX0x3qT9Ornf97myjpoKs22eNMNybp6dAunjxQJp0YAKsHGlXKPwfaexjp1q0rRl//hAAr68ew8nFVVGFPPNNqyQBdbj7npwNUKvkZSpn0jw+a3flR1VC1SA5D4uUsvTUk+0HpESxPos2bRa7goyDKoYqM9+S17u6OAj1uOluhMwouSG53ks3rhCbuRKNf0t1qs4UAG2fFu2+cpZ1rK9DtbhD1ITA2u5qn2VrC42k5ZFerddprNcl8eNYVw92Xr4Bm5quWrDKlnOlWq4CkVEawqoAHOX6PEi/MYYxtaXsQw/JvW6Wb6xL1NfpdhDflnNjLbLdFbM5U9imFBn1mGAUQELSfVYvvE6ubYcn1mWPaU3rZHnSHKKtWytW1c1DtLUigG1LCsXSMvGqL1t+iV6YOsY7hXDW2py55WRDtKggssryR4WP3ud/N9yfrYp54e9+B3Z2rOXUxTurc9g1RhIXVDhRT/JBeUGadkZNdzmL9MfolwUaPA6EKq5GRd8jz9teIr3co9UZA1yRWeG5y3WK43DVSmLo96qMbIPdtG7c8NKOb2iF0slP3zDKlnueZyMQ3s9yKpKPdrhWRZUGqQVZ9Te1vYFPcht5SdOnHfbZF0KVHyobwCboN3zuOTZ6+Q/qoTcq6fNXarLHMNioLU+OVABMATrm5IKP96wQv6+ylRIdbW2y/REt4Hr3BiH+z11di0bi8ZAfbb5HldvXC3fr0K5XJ1t7hJdaQyfM4Zxtu65llSLKnSrzx0bVsqnq/ZCqtYvtnGlLFHlE1Z51DRU8zet3YjeaQSrPOMluaCaQVrVjBpusxfpZ43LVY4wQ21dDgy1x1Ms+hIJ1q1fLdfVyNeujTZpgU6ZNJ6volwiDg3WqwN2wAANdiBBfW5KdnP9c9+Sv9bQ16+9Nm+Z3miEUxEmqV8HbEEMannDWh7ojnPu5uXSXYM/ozbbrK/qcfEGLgQuMA5OnWGze9a4QQkg9fmZ7/Oj9tVyTw3/nNpvc5fqdxzDqcAMgJHswfYu/VF40U9yX/tquXCYXHfDo02/XA9sjfE54GNOA4fZZAqwI4FlBYwTaFC/h3Yc/i2xj9/WkgYdMUBNcwm+otPcBtaJy9vVMkOcYQjaFDitD+LwgrU8Tg/f2PBN+cswvRaHb5t5qc5pbOIjwIeBE00cV72QNNDa66k+5uzGF5c7reWRnje44/nvyuZhPmiMnDZ7sX7MgQtNjNlqOdA4jFIF/MHv2FzSzknt3oeAeuwTl5d8j/We5Z+fWyW/G2Gx4Qhsp2rznLfw0VgDb7IeR6rleLeJMWqBwGfMvbVOCXsgbdNeB7wutiP8t+PwB7+LLRvWyE9HsIlRb72tbbH+o1jeEWvgbUCjWg5AaRZDS2qD2bTtHiHP5mahs5tZO79vJz4PrNJhDHsx7ETZ7Sd4Rg0PDWbnkDpQR2ibc7l+QAzjxXCQG8OxPgep5WCECWppQhiD0gK0oDQBjgiuQhLFqtAp0IWwG9iH0iGGXeKwSYTtNkmPtWy1Pq88u0Yerp/x3O3/A6qXxURxUsm4AAAAAElFTkSuQmCC", + iconSize: [30, 30], + iconAnchor: [15, 15] + }); + if (angular.isDefined(vm.ctx.settings.markerImage)) { + staticSettings.icon = L.icon({ + iconUrl: vm.ctx.settings.markerImage, + iconSize: [staticSettings.markerImageSize, staticSettings.markerImageSize], + iconAnchor: [(staticSettings.markerImageSize / 2), (staticSettings.markerImageSize / 2)] + }) + } + + if (staticSettings.usePathColorFunction && angular.isDefined(vm.ctx.settings.colorFunction)) { + staticSettings.colorFunction = new Function('data, dsData, dsIndex', vm.ctx.settings.colorFunction); + } + + if (staticSettings.useLabelFunction && angular.isDefined(vm.ctx.settings.labelFunction)) { + staticSettings.labelFunction = new Function('data, dsData, dsIndex', vm.ctx.settings.labelFunction); + } + + if (staticSettings.useTooltipFunction && angular.isDefined(vm.ctx.settings.tooltipFunction)) { + staticSettings.tooltipFunction = new Function('data, dsData, dsIndex', vm.ctx.settings.tooltipFunction); + } + + if (staticSettings.useMarkerImageFunction && angular.isDefined(vm.ctx.settings.markerImageFunction)) { + staticSettings.markerImageFunction = new Function('data, images, dsData, dsIndex', vm.ctx.settings.markerImageFunction); + } + + if (!staticSettings.useMarkerImageFunction && + angular.isDefined(vm.ctx.settings.markerImage) && + vm.ctx.settings.markerImage.length > 0) { + staticSettings.useMarkerImage = true; + let url = vm.ctx.settings.markerImage; + let size = staticSettings.markerImageSize || 20; + staticSettings.currentImage = { + url: url, + size: size + }; + vm.utils.loadImageAspect(staticSettings.currentImage.url).then( + (aspect) => { + if (aspect) { + let width; + let height; + if (aspect > 1) { + width = staticSettings.currentImage.size; + height = staticSettings.currentImage.size / aspect; + } else { + width = staticSettings.currentImage.size * aspect; + height = staticSettings.currentImage.size; + } + staticSettings.icon = L.icon({ + iconUrl: staticSettings.currentImage.url, + iconSize: [width, height], + iconAnchor: [width / 2, height / 2] + }); + } + if (vm.trips) { + vm.trips.forEach(function (trip) { + if (trip.marker) { + trip.marker.setIcon(staticSettings.icon); + } + }); + } + } + ) + } + } + + function configureTripSettings(trip) { + trip.settings = {}; + trip.settings.color = calculateColor(trip); + trip.settings.strokeWeight = vm.staticSettings.pathWeight; + trip.settings.strokeOpacity = vm.staticSettings.pathOpacity; + trip.settings.pointColor = vm.staticSettings.pointColor; + trip.settings.pointSize = vm.staticSettings.pointSize; + trip.settings.labelText = calculateLabel(trip); + trip.settings.tooltipText = calculateTooltip(trip); + trip.settings.icon = calculateIcon(trip); + } + + function calculateLabel(trip) { + let label = ''; + if (vm.staticSettings.showLabel) { + let labelReplaceInfo; + let labelText = vm.staticSettings.label; + if (vm.staticSettings.useLabelFunction && angular.isDefined(vm.staticSettings.labelFunction)) { + try { + labelText = vm.staticSettings.labelFunction(vm.ctx.data, trip.timeRange[vm.index], trip.dsIndex); + } catch (e) { + labelText = null; + } + } + labelText = vm.utils.createLabelFromDatasource(trip.dataSource, labelText); + labelReplaceInfo = processPattern(labelText, vm.ctx.datasources, trip.dSIndex); + label = fillPattern(labelText, labelReplaceInfo, trip.timeRange[vm.index]); + if (vm.staticSettings.useLabelFunction && angular.isDefined(vm.staticSettings.labelFunction)) { + try { + labelText = vm.staticSettings.labelFunction(vm.ctx.data, trip.timeRange[vm.index], trip.dSIndex); + } catch (e) { + labelText = null; + } + } + } + return label; + } + + function calculateTooltip(trip) { + let tooltip = ''; + if (vm.staticSettings.displayTooltip) { + let tooltipReplaceInfo; + let tooltipText = vm.staticSettings.tooltipPattern; + if (vm.staticSettings.useTooltipFunction && angular.isDefined(vm.staticSettings.tooltipFunction)) { + try { + tooltipText = vm.staticSettings.tooltipFunction(vm.ctx.data, trip.timeRange[vm.index], trip.dSIndex); + } catch (e) { + tooltipText = null; + } + } + tooltipText = vm.utils.createLabelFromDatasource(trip.dataSource, tooltipText); + tooltipReplaceInfo = processPattern(tooltipText, vm.ctx.datasources, trip.dSIndex); + tooltip = fillPattern(tooltipText, tooltipReplaceInfo, trip.timeRange[vm.index]); + tooltip = fillPatternWithActions(tooltip, 'onTooltipAction', null); + + } + return tooltip; + } + + function calculateColor(trip) { + let color = vm.staticSettings.pathColor; + let colorFn; + if (vm.staticSettings.usePathColorFunction && angular.isDefined(vm.staticSettings.colorFunction)) { + try { + colorFn = vm.staticSettings.colorFunction(vm.ctx.data, trip.timeRange[vm.index], trip.dSIndex); + } catch (e) { + colorFn = null; + } + } + if (colorFn && colorFn !== color && trip.polyline) { + trip.polyline.setStyle({color: colorFn}); + } + return colorFn || color; + } + + function calculateIcon(trip) { + let icon = vm.staticSettings.icon; + if (vm.staticSettings.useMarkerImageFunction && angular.isDefined(vm.staticSettings.markerImageFunction)) { + let rawIcon; + try { + rawIcon = vm.staticSettings.markerImageFunction(vm.ctx.data, vm.staticSettings.markerImages, trip.timeRange[vm.index], trip.dSIndex); + } catch (e) { + rawIcon = null; + } + if (rawIcon) { + vm.utils.loadImageAspect(rawIcon).then( + (aspect) => { + if (aspect) { + let width; + let height; + if (aspect > 1) { + width = rawIcon.size; + height = rawIcon.size / aspect; + } else { + width = rawIcon.size * aspect; + height = rawIcon.size; + } + icon = L.icon({ + iconUrl: rawIcon, + iconSize: [width, height], + iconAnchor: [width / 2, height / 2] + }); + } + if (trip.marker) { + trip.marker.setIcon(icon); + } + } + ) + } + } + return icon; + } + + function createUpdatePath() { + if (vm.trips && vm.map) { + vm.trips.forEach(function (trip) { + if (trip.marker) { + trip.marker.remove(); + delete trip.marker; + } + if (trip.polyline) { + trip.polyline.remove(); + delete trip.polyline; + } + if (trip.points && trip.points.length) { + trip.points.forEach(function (point) { + point.remove(); + }); + delete trip.points; + } + }); + vm.initBounds = true; + } + let normalizedTimeRange = createNormalizedTime(vm.data, 1000); + createNormalizedTrips(normalizedTimeRange, vm.datasources); + createTripsOnMap(); + if (vm.initBounds && !vm.initTrips) { + vm.trips.forEach(function (trip) { + vm.map.extendBounds(vm.map.bounds, trip.polyline); + vm.initBounds = !vm.datasources.every( + function (ds) { + return ds.dataReceived === true; + }); + vm.initTrips = vm.trips.every(function (trip) { + return angular.isDefined(trip.marker) && angular.isDefined(trip.polyline); + }); + }); + + vm.map.fitBounds(vm.map.bounds); + } + + } + + function fillPattern(pattern, replaceInfo, currentNormalizedValue) { + let text = angular.copy(pattern); + let reg = /\$\{([^\}]*)\}/g; + if (replaceInfo) { + for (let v = 0; v < replaceInfo.variables.length; v++) { + let variableInfo = replaceInfo.variables[v]; + let label = reg.exec(pattern)[1].split(":")[0]; + let txtVal = ''; + if (label.length > -1 && angular.isDefined(currentNormalizedValue[label])) { + let varData = currentNormalizedValue[label]; + if (isNumber(varData)) { + txtVal = padValue(varData, variableInfo.valDec, 0); + } else { + txtVal = varData; + } + } + text = text.split(variableInfo.variable).join(txtVal); + } + } + return text; + } + + function createNormalizedTime(data, step) { + if (!step) step = 1000; + let max_time = null; + let min_time = null; + let normalizedArray = []; + if (data && data.length > 0) { + vm.data.forEach(function (data) { + if (data.data.length > 0) { + data.data.forEach(function (sData) { + if (max_time === null) { + max_time = sData[0]; + } else if (max_time < sData[0]) { + max_time = sData[0] + } + if (min_time === null) { + min_time = sData[0]; + } else if (min_time > sData[0]) { + min_time = sData[0]; + } + }) + } + }); + for (let i = min_time; i < max_time; i += step) { + normalizedArray.push({ts: i}) + } + if (normalizedArray[normalizedArray.length - 1] && normalizedArray[normalizedArray.length - 1].ts !== max_time) normalizedArray.push({ts: max_time}); + } + vm.maxTime = normalizedArray.length - 1; + vm.minTime = 0; + return normalizedArray; + } + + function createNormalizedTrips(timeRange, dataSources) { + vm.trips = []; + if (timeRange && timeRange.length > 0 && dataSources && dataSources.length > 0 && vm.data && vm.data.length > 0) { + dataSources.forEach(function (dS, index) { + vm.trips.push({ + dataSource: dS, + dSIndex: index, + timeRange: angular.copy(timeRange) + }) + }); + + vm.data.forEach(function (data) { + let ds = data.datasource; + let tripIndex = vm.trips.findIndex(function (el) { + return el.dataSource.entityId === ds.entityId; + }); + + if (tripIndex > -1) { + createNormalizedValue(data.data, data.dataKey.label, vm.trips[tripIndex].timeRange); + } + }) + } + + createNormalizedLatLngs(); + } + + function createNormalizedValue(dataArray, dataKey, timeRangeArray) { + timeRangeArray.forEach(function (timeStamp) { + let targetTDiff = null; + let value = null; + for (let i = 0; i < dataArray.length; i++) { + let tDiff = dataArray[i][0] - timeStamp.ts; + if (targetTDiff === null || (tDiff <= 0 && targetTDiff < tDiff)) { + targetTDiff = tDiff; + value = dataArray[i][1]; + + } + } + if (value !== null) timeStamp[dataKey] = value; + }); + } + + function createNormalizedLatLngs() { + vm.trips.forEach(function (el) { + el.latLngs = []; + el.timeRange.forEach(function (data) { + let lat = data[vm.staticSettings.latKeyName]; + let lng = data[vm.staticSettings.lngKeyName]; + if (lat && lng && vm.map) { + data.latLng = vm.map.createLatLng(lat, lng); + } + el.latLngs.push(data.latLng); + }); + addAngleForTip(el); + }) + } + + function addAngleForTip(trip) { + if (trip.timeRange && trip.timeRange.length > 0) { + trip.timeRange.forEach(function (point, index) { + let nextPoint, prevPoint; + nextPoint = index === (trip.timeRange.length - 1) ? trip.timeRange[index] : trip.timeRange[index + 1]; + prevPoint = index === 0 ? trip.timeRange[0] : trip.timeRange[index - 1]; + point.h = findAngle(prevPoint[vm.staticSettings.latKeyName], prevPoint[vm.staticSettings.lngKeyName], nextPoint[vm.staticSettings.latKeyName], nextPoint[vm.staticSettings.lngKeyName]); + point.h += vm.staticSettings.rotationAngle; + }); + } + } + + function createTripsOnMap() { + if (vm.trips.length > 0) { + vm.trips.forEach(function (trip) { + if (trip.timeRange.length > 0 && trip.latLngs.every(el => angular.isDefined(el))) { + configureTripSettings(trip, vm.index); + if (vm.staticSettings.showPoints) { + trip.points = []; + trip.latLngs.forEach(function (latLng) { + let point = L.circleMarker(latLng, { + color: trip.settings.pointColor, + radius: trip.settings.pointSize + }).addTo(vm.map.map); + trip.points.push(point); + }); + } + + if (angular.isUndefined(trip.marker)) { + trip.polyline = vm.map.createPolyline(trip.latLngs, trip.settings); + } + + if (trip.timeRange && trip.timeRange.length && angular.isUndefined(trip.marker)) { + trip.marker = L.marker(trip.timeRange[vm.index].latLng).addTo(vm.map.map); + trip.marker.setZIndexOffset(1000); + trip.marker.setIcon(vm.staticSettings.icon); + trip.marker.setRotationOrigin('center center'); + // trip.marker.addTo(vm.map.map); + trip.marker.on('click', function () { + showHideTooltip(trip); + }); + moveMarker(trip); + } + } + }); + } + } + + function moveMarker(trip) { + if (angular.isDefined(trip.marker)) { + trip.markerAngleIsSet = true; + trip.marker.setLatLng(trip.timeRange[vm.index].latLng); + trip.marker.setRotationAngle(trip.timeRange[vm.index].h); + trip.marker.update(); + } else { + if (trip.timeRange && trip.timeRange.length) { + trip.marker = L.marker(trip.timeRange[vm.index].latLng); + trip.marker.setZIndexOffset(1000); + trip.marker.setIcon(vm.staticSettings.icon); + trip.marker.setRotationOrigin('center center'); + trip.marker.addTo(vm.map.map); + trip.marker.on('click', function () { + showHideTooltip(trip); + }); + trip.marker.update(); + } + + } + configureTripSettings(trip); + } + + + function showHideTooltip(trip) { + if (vm.staticSettings.displayTooltip) { + if (vm.staticSettings.showTooltip && trip && vm.activeTripIndex !== trip.dSIndex) { + vm.staticSettings.showTooltip = true; + } else { + vm.staticSettings.showTooltip = !vm.staticSettings.showTooltip; + } + } + if (trip && vm.activeTripIndex !== trip.dSIndex) vm.activeTripIndex = trip.dSIndex; + } +} \ No newline at end of file diff --git a/ui/src/app/widget/lib/tripAnimation/trip-animation-widget.scss b/ui/src/app/widget/lib/tripAnimation/trip-animation-widget.scss new file mode 100644 index 0000000000..1b6ae956c3 --- /dev/null +++ b/ui/src/app/widget/lib/tripAnimation/trip-animation-widget.scss @@ -0,0 +1,106 @@ +/** + * Copyright © 2016-2019 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.heat-map-widget { + position: relative; + width: 100%; + height: 100%; + font-size: 16px; + line-height: 24px; +} + +.heat-map-info-panel { + position: absolute; + top: 0; + right: 0; + z-index: 2; + background-color: rgba(0, 0, 0, .3); + border-bottom-left-radius: 5px; + + .md-button { + min-width: auto; + } +} + +.heat-map-tooltip { + position: absolute; + top: 47px; + right: 0; + z-index: 2; + padding: 10px; + background-color: #fff; + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; + transition: .3s ease-in-out; + + &-hidden { + transform: translateX(100%); + } +} + +.heat-map-title { + padding: 10px; +} + +.heat-map-control-panel { + position: absolute; + bottom: 0; + z-index: 2; + box-sizing: border-box; + width: 100%; + padding-right: 70px; + padding-left: 20px; + background: rgba(0, 0, 0, .3); + + md-slider-container { + button { + max-width: none; + + ng-md-icon { + width: 36px; + height: 36px; + + svg { + width: inherit; + height: inherit; + } + } + } + + .panel-timer { + max-width: none; + font-size: 20px; + font-weight: 600; + } + } +} + +.heat-map-container { + position: relative; + z-index: 1; + flex: 1; + width: 100%; +} + +#heat-map { + z-index: 1; + width: 100%; + height: 100%; + + .pointsLayerMarkerIcon { + border-radius: 50%; + } +} diff --git a/ui/src/app/widget/lib/tripAnimation/trip-animation-widget.tpl.html b/ui/src/app/widget/lib/tripAnimation/trip-animation-widget.tpl.html new file mode 100644 index 0000000000..5ffc6d56f9 --- /dev/null +++ b/ui/src/app/widget/lib/tripAnimation/trip-animation-widget.tpl.html @@ -0,0 +1,54 @@ + +
    +
    + {{vm.trips[vm.activeTripIndex].settings.labelText}} +
    +
    + + + + + + + {{ speed}} + + + + + +
    {{vm.trips[vm.activeTripIndex].timeRange[vm.index].ts | date:'medium'}} +
    +
    +
    +
    +
    +
    + + + + + + +
    +
    +
    +
    +
    \ No newline at end of file diff --git a/ui/src/png/jstree/32px.png b/ui/src/png/jstree/32px.png new file mode 100644 index 0000000000..719a6bcdbd Binary files /dev/null and b/ui/src/png/jstree/32px.png differ diff --git a/ui/src/png/jstree/40px.png b/ui/src/png/jstree/40px.png new file mode 100644 index 0000000000..1959347aea Binary files /dev/null and b/ui/src/png/jstree/40px.png differ