diff --git a/.gitignore b/.gitignore index 9039e8ec4d..da8569baa3 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ pom.xml.versionsBackup **/Californium.properties **/.env .instance_id +rebuild-docker.sh diff --git a/.travis.yml b/.travis.yml index d3591cf1a4..e8d88478d2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,6 +2,7 @@ before_install: - sudo rm -f /etc/mavenrc - export M2_HOME=/usr/local/maven - export MAVEN_OPTS="-Dmaven.repo.local=$HOME/.m2/repository -Xms1024m -Xmx3072m" + - export HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE=false jdk: - oraclejdk8 language: java diff --git a/application/pom.xml b/application/pom.xml index 3d7cbe1848..d99f60773e 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard application @@ -57,25 +57,29 @@ rule-engine-components - org.thingsboard.common - transport + org.thingsboard.common.transport + transport-api - org.thingsboard.transport - http + org.thingsboard.common.transport + mqtt - org.thingsboard.transport - coap + org.thingsboard.common.transport + http - org.thingsboard.transport - mqtt + org.thingsboard.common.transport + coap org.thingsboard dao + + org.thingsboard.common + queue + org.thingsboard dao @@ -538,7 +542,8 @@ -PprojectBuildDir=${project.build.directory} -PprojectVersion=${project.version} - -PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + -PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + -PpkgName=${pkg.name} -PpkgInstallFolder=${pkg.installFolder} -PpkgLogFolder=${pkg.unixLogFolder} @@ -572,6 +577,27 @@ + + org.apache.maven.plugins + maven-install-plugin + + ${project.build.directory}/${pkg.name}.deb + ${project.artifactId} + ${project.groupId} + ${project.version} + deb + deb + + + + install-deb + package + + install-file + + + + org.xolstice.maven.plugins protobuf-maven-plugin 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 803f1d3953..6550f58da4 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -15,7 +15,7 @@ "resources": [], "templateHtml": "", "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}", - "controllerScript": "self.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n \n for (var i=0; i < self.ctx.datasources.length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = 'tbDatasource' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = 'labelCell' + a;\n var cellId = 'cell' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $('#' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $('#' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData && cellData.data && cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n self.ctx.valueCells[i].html(value);\n }\n } \n}\n\nself.onResize = function() {\n var datasoirceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasoirceTitleFontSize = self.ctx.width/12;\n }\n datasoirceTitleFontSize = Math.min(datasoirceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css('font-size', datasoirceTitleFontSize+'px');\n }\n var valueFontSize = self.ctx.height/9;\n var labelFontSize = self.ctx.height/9;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n valueFontSize = self.ctx.width/15;\n labelFontSize = self.ctx.width/15;\n }\n valueFontSize = Math.min(valueFontSize, 18);\n labelFontSize = Math.min(labelFontSize, 18);\n\n for (i = 0; i < self.ctx.valueCells; i++) {\n self.ctx.valueCells[i].css('font-size', valueFontSize+'px');\n self.ctx.valueCells[i].css('height', valueFontSize*2.5+'px');\n self.ctx.valueCells[i].css('padding', '0px ' + valueFontSize + 'px');\n self.ctx.labelCells[i].css('font-size', labelFontSize+'px');\n self.ctx.labelCells[i].css('height', labelFontSize*2.5+'px');\n self.ctx.labelCells[i].css('padding', '0px ' + labelFontSize + 'px');\n } \n}\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n \n for (var i=0; i < self.ctx.datasources.length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = 'tbDatasource' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $('.tbDatasource-title', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = 'table' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = 'labelCell' + a;\n var cellId = 'cell' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $('#' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $('#' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData && cellData.data && cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var textValue;\n //toDo -> + IsNumber\n \n if (isNumber(value)) {\n var decimals = self.ctx.decimals;\n var units = self.ctx.units;\n if (cellData.dataKey.decimals || cellData.dataKey.decimals === 0) {\n decimals = cellData.dataKey.decimals;\n }\n if (cellData.dataKey.units) {\n units = cellData.dataKey.units;\n }\n txtValue = self.ctx.utils.formatValue(value, decimals, units, true);\n } else {\n txtValue = value;\n }\n self.ctx.valueCells[i].html(txtValue);\n }\n }\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n}\n\nself.onResize = function() {\n var datasoirceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasoirceTitleFontSize = self.ctx.width/12;\n }\n datasoirceTitleFontSize = Math.min(datasoirceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css('font-size', datasoirceTitleFontSize+'px');\n }\n var valueFontSize = self.ctx.height/9;\n var labelFontSize = self.ctx.height/9;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n valueFontSize = self.ctx.width/15;\n labelFontSize = self.ctx.width/15;\n }\n valueFontSize = Math.min(valueFontSize, 18);\n labelFontSize = Math.min(labelFontSize, 18);\n\n for (i = 0; i < self.ctx.valueCells; i++) {\n self.ctx.valueCells[i].css('font-size', valueFontSize+'px');\n self.ctx.valueCells[i].css('height', valueFontSize*2.5+'px');\n self.ctx.valueCells[i].css('padding', '0px ' + valueFontSize + 'px');\n self.ctx.labelCells[i].css('font-size', labelFontSize+'px');\n self.ctx.labelCells[i].css('height', labelFontSize*2.5+'px');\n self.ctx.labelCells[i].css('padding', '0px ' + labelFontSize + 'px');\n } \n}\n\nself.onDestroy = function() {\n}\n", "settingsSchema": "{}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"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;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Attributes card\"}" @@ -27,7 +27,7 @@ "descriptor": { "type": "latest", "sizeX": 7.5, - "sizeY": 4.5, + "sizeY": 6.5, "resources": [], "templateHtml": "\n", "templateCss": "", @@ -95,7 +95,7 @@ "resources": [], "templateHtml": "", "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n\n.tbDatasource-table {\n width: 100%;\n height: 100%;\n border-collapse: collapse;\n white-space: nowrap;\n font-weight: 100;\n text-align: right;\n}\n\n.tbDatasource-table td {\n padding: 12px;\n position: relative;\n box-sizing: border-box;\n}\n\n.tbDatasource-data-key {\n opacity: 0.7;\n font-weight: 400;\n font-size: 3.500rem;\n}\n\n.tbDatasource-value {\n font-size: 5.000rem;\n}", - "controllerScript": "self.onInit = function() {\n\n self.ctx.labelPosition = self.ctx.settings.labelPosition || 'left';\n \n if (self.ctx.datasources.length > 0) {\n var tbDatasource = self.ctx.datasources[0];\n var datasourceId = 'tbDatasource' + 0;\n self.ctx.$container.append(\n \"
\"\n );\n \n self.ctx.datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n \n var tableId = 'table' + 0;\n self.ctx.datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n if (self.ctx.labelPosition === 'top') {\n table.css('text-align', 'left');\n }\n \n if (tbDatasource.dataKeys.length > 0) {\n var dataKey = tbDatasource.dataKeys[0];\n var labelCellId = 'labelCell' + 0;\n var cellId = 'cell' + 0;\n if (self.ctx.labelPosition === 'left') {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n } else {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n }\n self.ctx.labelCell = $('#' + labelCellId, table);\n self.ctx.valueCell = $('#' + cellId, table);\n self.ctx.valueCell.html(0 + ' ' + self.ctx.units);\n }\n }\n \n $.fn.textWidth = function(){\n var html_org = $(this).html();\n var html_calc = '' + html_org + '';\n $(this).html(html_calc);\n var width = $(this).find('span:first').width();\n $(this).html(html_org);\n return width;\n }; \n \n self.onResize();\n};\n\nself.onDataUpdated = function() {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n\n if (self.ctx.valueCell && self.ctx.data.length > 0) {\n var cellData = self.ctx.data[0];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var txtValue;\n if (isNumber(value)) {\n txtValue = self.ctx.utils.formatValue(value, self.ctx.decimals, self.ctx.units);\n } else {\n txtValue = value;\n }\n self.ctx.valueCell.html(txtValue);\n var targetWidth;\n var minDelta;\n if (self.ctx.labelPosition === 'left') {\n targetWidth = self.ctx.datasourceContainer.width() - self.ctx.labelCell.width();\n minDelta = self.ctx.width/16 + self.ctx.padding;\n } else {\n targetWidth = self.ctx.datasourceContainer.width();\n minDelta = self.ctx.padding;\n }\n var delta = targetWidth - self.ctx.valueCell.textWidth();\n var fontSize = self.ctx.valueFontSize;\n if (targetWidth > minDelta) {\n while (delta < minDelta && fontSize > 6) {\n fontSize--;\n self.ctx.valueCell.css('font-size', fontSize+'px');\n delta = targetWidth - self.ctx.valueCell.textWidth();\n }\n }\n }\n } \n \n};\n\nself.onResize = function() {\n var labelFontSize;\n if (self.ctx.labelPosition === 'top') {\n self.ctx.padding = self.ctx.height/20;\n labelFontSize = self.ctx.height/4;\n self.ctx.valueFontSize = self.ctx.height/2;\n } else {\n self.ctx.padding = self.ctx.width/50;\n labelFontSize = self.ctx.height/2.5;\n self.ctx.valueFontSize = self.ctx.height/2;\n if (self.ctx.width/self.ctx.height <= 2.7) {\n labelFontSize = self.ctx.width/7;\n self.ctx.valueFontSize = self.ctx.width/6;\n }\n }\n self.ctx.padding = Math.min(12, self.ctx.padding);\n \n if (self.ctx.labelCell) {\n self.ctx.labelCell.css('font-size', labelFontSize+'px');\n self.ctx.labelCell.css('padding', self.ctx.padding+'px');\n }\n if (self.ctx.valueCell) {\n self.ctx.valueCell.css('font-size', self.ctx.valueFontSize+'px');\n self.ctx.valueCell.css('padding', self.ctx.padding+'px');\n } \n};\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1\n };\n};\n\n\nself.onDestroy = function() {\n};\n", + "controllerScript": "self.onInit = function() {\n\n self.ctx.labelPosition = self.ctx.settings.labelPosition || 'left';\n \n if (self.ctx.datasources.length > 0) {\n var tbDatasource = self.ctx.datasources[0];\n var datasourceId = 'tbDatasource' + 0;\n self.ctx.$container.append(\n \"
\"\n );\n \n self.ctx.datasourceContainer = $('#' + datasourceId,\n self.ctx.$container);\n \n var tableId = 'table' + 0;\n self.ctx.datasourceContainer.append(\n \"
\"\n );\n var table = $('#' + tableId, self.ctx.$container);\n if (self.ctx.labelPosition === 'top') {\n table.css('text-align', 'left');\n }\n \n if (tbDatasource.dataKeys.length > 0) {\n var dataKey = tbDatasource.dataKeys[0];\n var labelCellId = 'labelCell' + 0;\n var cellId = 'cell' + 0;\n if (self.ctx.labelPosition === 'left') {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n } else {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n }\n self.ctx.labelCell = $('#' + labelCellId, table);\n self.ctx.valueCell = $('#' + cellId, table);\n self.ctx.valueCell.html(0 + ' ' + self.ctx.units);\n }\n }\n \n $.fn.textWidth = function(){\n var html_org = $(this).html();\n var html_calc = '' + html_org + '';\n $(this).html(html_calc);\n var width = $(this).find('span:first').width();\n $(this).html(html_org);\n return width;\n }; \n \n self.onResize();\n};\n\nself.onDataUpdated = function() {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n\n if (self.ctx.valueCell && self.ctx.data.length > 0) {\n var cellData = self.ctx.data[0];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var txtValue;\n if (isNumber(value)) {\n var decimals = self.ctx.decimals;\n var units = self.ctx.units;\n if (self.ctx.datasources.length > 0 && self.ctx.datasources[0].dataKeys.length > 0) {\n dataKey = self.ctx.datasources[0].dataKeys[0];\n if (dataKey.decimals || dataKey.decimals === 0) {\n decimals = dataKey.decimals;\n }\n if (dataKey.units) {\n units = dataKey.units;\n }\n }\n txtValue = self.ctx.utils.formatValue(value, decimals, units, true);\n } else {\n txtValue = value;\n }\n self.ctx.valueCell.html(txtValue);\n var targetWidth;\n var minDelta;\n if (self.ctx.labelPosition === 'left') {\n targetWidth = self.ctx.datasourceContainer.width() - self.ctx.labelCell.width();\n minDelta = self.ctx.width/16 + self.ctx.padding;\n } else {\n targetWidth = self.ctx.datasourceContainer.width();\n minDelta = self.ctx.padding;\n }\n var delta = targetWidth - self.ctx.valueCell.textWidth();\n var fontSize = self.ctx.valueFontSize;\n if (targetWidth > minDelta) {\n while (delta < minDelta && fontSize > 6) {\n fontSize--;\n self.ctx.valueCell.css('font-size', fontSize+'px');\n delta = targetWidth - self.ctx.valueCell.textWidth();\n }\n }\n }\n } \n \n};\n\nself.onResize = function() {\n var labelFontSize;\n if (self.ctx.labelPosition === 'top') {\n self.ctx.padding = self.ctx.height/20;\n labelFontSize = self.ctx.height/4;\n self.ctx.valueFontSize = self.ctx.height/2;\n } else {\n self.ctx.padding = self.ctx.width/50;\n labelFontSize = self.ctx.height/2.5;\n self.ctx.valueFontSize = self.ctx.height/2;\n if (self.ctx.width/self.ctx.height <= 2.7) {\n labelFontSize = self.ctx.width/7;\n self.ctx.valueFontSize = self.ctx.width/6;\n }\n }\n self.ctx.padding = Math.min(12, self.ctx.padding);\n \n if (self.ctx.labelCell) {\n self.ctx.labelCell.css('font-size', labelFontSize+'px');\n self.ctx.labelCell.css('padding', self.ctx.padding+'px');\n }\n if (self.ctx.valueCell) {\n self.ctx.valueCell.css('font-size', self.ctx.valueFontSize+'px');\n self.ctx.valueCell.css('padding', self.ctx.padding+'px');\n } \n};\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1\n };\n};\n\n\nself.onDestroy = function() {\n};\n", "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"labelPosition\": {\n \"title\": \"Label position\",\n \"type\": \"string\",\n \"default\": \"left\"\n }\n },\n \"required\": []\n },\n \"form\": [\n {\n \"key\": \"labelPosition\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"left\",\n \"label\": \"Left\"\n },\n {\n \"value\": \"top\",\n \"label\": \"Top\"\n }\n ]\n }\n ]\n}", "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.2392660816082064,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ff5722\",\"color\":\"rgba(255, 255, 255, 0.87)\",\"padding\":\"16px\",\"settings\":{\"labelPosition\":\"top\"},\"title\":\"Simple card\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"units\":\"°C\",\"decimals\":0,\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{}}" diff --git a/application/src/main/data/json/system/widget_bundles/charts.json b/application/src/main/data/json/system/widget_bundles/charts.json index a264ba6515..71e9fa7ace 100644 --- a/application/src/main/data/json/system/widget_bundles/charts.json +++ b/application/src/main/data/json/system/widget_bundles/charts.json @@ -35,7 +35,7 @@ "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 self.ctx.flot = new TbFlot(self.ctx); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema('graph');\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", "settingsSchema": "{}", "dataKeySettingsSchema": "{}", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"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;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":false,\"tooltipIndividual\":false},\"title\":\"Timeseries - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}" @@ -147,10 +147,10 @@ "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 self.ctx.flot = new TbFlot(self.ctx, 'bar'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(false);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, 'bar'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema('bar');\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(false);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", "settingsSchema": "{}", "dataKeySettingsSchema": "{}", - "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000},\"aggregation\":{\"limit\":200,\"type\":\"AVG\"}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":true,\"tooltipIndividual\":false},\"title\":\"Timeseries Bars - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}" + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000},\"aggregation\":{\"limit\":200,\"type\":\"AVG\"}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"stack\":true,\"tooltipIndividual\":false,\"defaultBarWidth\":600},\"title\":\"Timeseries Bars - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":true,\"actions\":{}}" } }, { @@ -163,7 +163,7 @@ "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 self.ctx.flot = new TbFlot(self.ctx, 'state'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.typeParameters = function() {\n return {\n stateData: true\n };\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, 'state'); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.typeParameters = function() {\n return {\n stateData: true\n };\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema('graph');\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n", "settingsSchema": "{}", "dataKeySettingsSchema": "{}", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 1\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false,\"axisPosition\":\"left\",\"showSeparateAxis\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"return Math.random() > 0.5 ? 1 : 0;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Switch 2\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false,\"axisPosition\":\"left\"},\"_hash\":0.12775350966079668,\"funcBody\":\"return Math.random() <= 0.5 ? 1 : 0;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\",\"ticksFormatter\":\"if (value > 0 && value <= 1) {\\n return 'On';\\n} else if (value === 0) {\\n return 'Off';\\n} else {\\n return '';\\n}\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"stack\":false,\"tooltipIndividual\":false,\"tooltipValueFormatter\":\"if (value > 0 && value <= 1) {\\n return 'On';\\n} else if (value === 0) {\\n return 'Off';\\n} else {\\n return '';\\n}\",\"smoothLines\":false},\"title\":\"State Chart\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null,\"widgetStyle\":{},\"useDashboardTimewindow\":true,\"showLegend\":true,\"actions\":{},\"legendConfig\":{\"position\":\"bottom\",\"showMin\":false,\"showMax\":false,\"showAvg\":false,\"showTotal\":false}}" diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.cql b/application/src/main/data/upgrade/2.1.1/schema_update.cql new file mode 100644 index 0000000000..36ac8e4d38 --- /dev/null +++ b/application/src/main/data/upgrade/2.1.1/schema_update.cql @@ -0,0 +1,81 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_name; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_customer; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_entity_id; + +DROP TABLE IF EXISTS thingsboard.entity_views; + +CREATE TABLE IF NOT EXISTS thingsboard.entity_views ( + id timeuuid, + entity_id timeuuid, + entity_type text, + tenant_id timeuuid, + customer_id timeuuid, + name text, + keys text, + start_ts bigint, + end_ts bigint, + search_text text, + additional_info text, + PRIMARY KEY (id, entity_id, tenant_id, customer_id) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_name AS + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, name, id, customer_id, entity_id) + WITH CLUSTERING ORDER BY (name ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_search_text AS + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, search_text, id, customer_id, entity_id) + WITH CLUSTERING ORDER BY (search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer AS + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id) + WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_id AS + SELECT * + from thingsboard.entity_views + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, customer_id, search_text, id) + WITH CLUSTERING ORDER BY (entity_id DESC, customer_id DESC, search_text ASC, id DESC); \ No newline at end of file diff --git a/application/src/main/data/upgrade/2.1.1/schema_update.sql b/application/src/main/data/upgrade/2.1.1/schema_update.sql new file mode 100644 index 0000000000..bd2c341e7f --- /dev/null +++ b/application/src/main/data/upgrade/2.1.1/schema_update.sql @@ -0,0 +1,31 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +DROP TABLE IF EXISTS entity_views; + +CREATE TABLE IF NOT EXISTS entity_views ( + id varchar(31) NOT NULL CONSTRAINT entity_view_pkey PRIMARY KEY, + entity_id varchar(31), + entity_type varchar(255), + tenant_id varchar(31), + customer_id varchar(31), + name varchar(255), + keys varchar(255), + start_ts bigint, + end_ts bigint, + search_text varchar(255), + additional_info varchar +); diff --git a/application/src/main/data/upgrade/2.1.2/schema_update.cql b/application/src/main/data/upgrade/2.1.2/schema_update.cql new file mode 100644 index 0000000000..c02571ada4 --- /dev/null +++ b/application/src/main/data/upgrade/2.1.2/schema_update.cql @@ -0,0 +1,110 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_name; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_search_text; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_customer; +DROP MATERIALIZED VIEW IF EXISTS thingsboard.entity_view_by_tenant_and_entity_id; + +DROP TABLE IF EXISTS thingsboard.entity_views; + +CREATE TABLE IF NOT EXISTS thingsboard.entity_view ( + id timeuuid, + entity_id timeuuid, + entity_type text, + tenant_id timeuuid, + customer_id timeuuid, + name text, + type text, + keys text, + start_ts bigint, + end_ts bigint, + search_text text, + additional_info text, + PRIMARY KEY (id, entity_id, tenant_id, customer_id, type) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_name AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, name, id, customer_id, entity_id, type) + WITH CLUSTERING ORDER BY (name ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_search_text AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, search_text, id, customer_id, entity_id, type) + WITH CLUSTERING ORDER BY (search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_by_type_and_search_text AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, type, search_text, id, customer_id, entity_id) + WITH CLUSTERING ORDER BY (type ASC, search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id, type) + WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer_and_type AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, type, customer_id, search_text, id, entity_id) + WITH CLUSTERING ORDER BY (type ASC, customer_id DESC, search_text ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_id AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, customer_id, search_text, id, type) + WITH CLUSTERING ORDER BY (entity_id DESC, customer_id DESC, search_text ASC, id DESC); \ No newline at end of file diff --git a/application/src/main/data/upgrade/2.1.2/schema_update.sql b/application/src/main/data/upgrade/2.1.2/schema_update.sql new file mode 100644 index 0000000000..14b717c07c --- /dev/null +++ b/application/src/main/data/upgrade/2.1.2/schema_update.sql @@ -0,0 +1,32 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +DROP TABLE IF EXISTS entity_views; + +CREATE TABLE IF NOT EXISTS entity_view ( + id varchar(31) NOT NULL CONSTRAINT entity_view_pkey PRIMARY KEY, + entity_id varchar(31), + entity_type varchar(255), + tenant_id varchar(31), + customer_id varchar(31), + type varchar(255), + name varchar(255), + keys varchar(255), + start_ts bigint, + end_ts bigint, + search_text varchar(255), + additional_info varchar +); diff --git a/application/src/main/data/upgrade/2.2.0/schema_update.sql b/application/src/main/data/upgrade/2.2.0/schema_update.sql new file mode 100644 index 0000000000..1832b7901b --- /dev/null +++ b/application/src/main/data/upgrade/2.2.0/schema_update.sql @@ -0,0 +1,17 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +ALTER TABLE component_descriptor ADD UNIQUE (clazz); diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index d33734e14c..091b504ea7 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -31,6 +31,7 @@ import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.server.actors.service.ActorService; @@ -48,6 +49,7 @@ import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; @@ -62,12 +64,13 @@ import org.thingsboard.server.service.encoding.DataDecodingEncodingService; import org.thingsboard.server.service.executors.DbCallbackExecutorService; import org.thingsboard.server.service.executors.ExternalCallExecutorService; import org.thingsboard.server.service.mail.MailExecutorService; -import org.thingsboard.server.service.queue.MsgQueueService; import org.thingsboard.server.service.rpc.DeviceRpcService; import org.thingsboard.server.service.script.JsExecutorService; -import org.thingsboard.server.service.script.JsSandboxService; +import org.thingsboard.server.service.script.JsInvokeService; +import org.thingsboard.server.service.session.DeviceSessionCacheService; import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; +import org.thingsboard.server.service.transport.RuleEngineTransportService; import javax.annotation.Nullable; import java.io.IOException; @@ -159,6 +162,10 @@ public class ActorSystemContext { @Getter private AuditLogService auditLogService; + @Autowired + @Getter + private EntityViewService entityViewService; + @Autowired @Getter private TelemetrySubscriptionService tsSubService; @@ -169,7 +176,7 @@ public class ActorSystemContext { @Autowired @Getter - private JsSandboxService jsSandbox; + private JsInvokeService jsSandbox; @Autowired @Getter @@ -193,11 +200,16 @@ public class ActorSystemContext { @Autowired @Getter - private MsgQueueService msgQueueService; + private DeviceStateService deviceStateService; + + @Autowired + @Getter + private DeviceSessionCacheService deviceSessionCacheService; + @Lazy @Autowired @Getter - private DeviceStateService deviceStateService; + private RuleEngineTransportService ruleEngineTransportService; @Value("${cluster.partition_id}") @Getter @@ -247,17 +259,21 @@ public class ActorSystemContext { @Getter private boolean allowSystemMailService; + @Value("${transport.sessions.inactivity_timeout}") @Getter - @Setter - private ActorSystem actorSystem; + private long sessionInactivityTimeout; + + @Value("${transport.sessions.report_timeout}") + @Getter + private long sessionReportTimeout; @Getter @Setter - private ActorRef appActor; + private ActorSystem actorSystem; @Getter @Setter - private ActorRef sessionManagerActor; + private ActorRef appActor; @Getter @Setter diff --git a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java index 6a78f78d1b..714ab577af 100644 --- a/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/app/AppActor.java @@ -25,21 +25,24 @@ import akka.actor.Terminated; import akka.event.Logging; import akka.event.LoggingAdapter; import akka.japi.Function; +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.ruleChain.RuleChainManagerActor; import org.thingsboard.server.actors.service.ContextBasedCreator; import org.thingsboard.server.actors.service.DefaultActorService; import org.thingsboard.server.actors.shared.rulechain.SystemRuleChainManager; import org.thingsboard.server.actors.tenant.TenantActor; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.aware.TenantAwareMsg; import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.core.BasicActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.dao.model.ModelConstants; @@ -52,16 +55,14 @@ import java.util.Optional; public class AppActor extends RuleChainManagerActor { - private final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); - - public static final TenantId SYSTEM_TENANT = new TenantId(ModelConstants.NULL_UUID); + private static final TenantId SYSTEM_TENANT = new TenantId(ModelConstants.NULL_UUID); private final TenantService tenantService; - private final Map tenantActors; + private final BiMap tenantActors; private AppActor(ActorSystemContext systemContext) { super(systemContext, new SystemRuleChainManager(systemContext)); this.tenantService = systemContext.getTenantService(); - this.tenantActors = new HashMap<>(); + this.tenantActors = HashBiMap.create(); } @Override @@ -71,22 +72,20 @@ public class AppActor extends RuleChainManagerActor { @Override public void preStart() { - logger.info("Starting main system actor."); + log.info("Starting main system actor."); try { initRuleChains(); - if (systemContext.isTenantComponentsInitEnabled()) { PageDataIterable tenantIterator = new PageDataIterable<>(tenantService::findTenants, ENTITY_PACK_LIMIT); for (Tenant tenant : tenantIterator) { - logger.debug("[{}] Creating tenant actor", tenant.getId()); + log.debug("[{}] Creating tenant actor", tenant.getId()); getOrCreateTenantActor(tenant.getId()); - logger.debug("Tenant actor created."); + log.debug("Tenant actor created."); } } - - logger.info("Main system actor started."); + log.info("Main system actor started."); } catch (Exception e) { - logger.error(e, "Unknown failure"); + log.warn("Unknown failure", e); } } @@ -105,7 +104,7 @@ public class AppActor extends RuleChainManagerActor { case SERVICE_TO_RULE_ENGINE_MSG: onServiceToRuleEngineMsg((ServiceToRuleEngineMsg) msg); break; - case DEVICE_SESSION_TO_DEVICE_ACTOR_MSG: + case TRANSPORT_TO_DEVICE_ACTOR_MSG: case DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG: case DEVICE_CREDENTIALS_UPDATE_TO_DEVICE_ACTOR_MSG: case DEVICE_NAME_OR_TYPE_UPDATE_TO_DEVICE_ACTOR_MSG: @@ -114,19 +113,12 @@ public class AppActor extends RuleChainManagerActor { case REMOTE_TO_RULE_CHAIN_TELL_NEXT_MSG: onToDeviceActorMsg((TenantAwareMsg) msg); break; - case ACTOR_SYSTEM_TO_DEVICE_SESSION_ACTOR_MSG: - onToDeviceSessionMsg((BasicActorSystemToDeviceSessionActorMsg) msg); - break; default: return false; } return true; } - private void onToDeviceSessionMsg(BasicActorSystemToDeviceSessionActorMsg msg) { - systemContext.getSessionManagerActor().tell(msg, self()); - } - private void onPossibleClusterMsg(SendToClusterMsg msg) { Optional address = systemContext.getRoutingService().resolveById(msg.getEntityId()); if (address.isPresent()) { @@ -139,7 +131,8 @@ public class AppActor extends RuleChainManagerActor { private void onServiceToRuleEngineMsg(ServiceToRuleEngineMsg msg) { if (SYSTEM_TENANT.equals(msg.getTenantId())) { - //TODO: ashvayka handle this. +// this may be a notification about system entities created. +// log.warn("[{}] Invalid service to rule engine msg called. System messages are not supported yet: {}", SYSTEM_TENANT, msg); } else { getOrCreateTenantActor(msg.getTenantId()).tell(msg, self()); } @@ -152,16 +145,26 @@ public class AppActor extends RuleChainManagerActor { } private void onComponentLifecycleMsg(ComponentLifecycleMsg msg) { - ActorRef target; + ActorRef target = null; if (SYSTEM_TENANT.equals(msg.getTenantId())) { target = getEntityActorRef(msg.getEntityId()); } else { - target = getOrCreateTenantActor(msg.getTenantId()); + if (msg.getEntityId().getEntityType() == EntityType.TENANT + && msg.getEvent() == ComponentLifecycleEvent.DELETED) { + log.debug("[{}] Handling tenant deleted notification: {}", msg.getTenantId(), msg); + ActorRef tenantActor = tenantActors.remove(new TenantId(msg.getEntityId().getId())); + if (tenantActor != null) { + log.debug("[{}] Deleting tenant actor: {}", msg.getTenantId(), tenantActor); + context().stop(tenantActor); + } + } else { + target = getOrCreateTenantActor(msg.getTenantId()); + } } if (target != null) { target.tell(msg, ActorRef.noSender()); } else { - logger.debug("Invalid component lifecycle msg: {}", msg); + log.debug("[{}] Invalid component lifecycle msg: {}", msg.getTenantId(), msg); } } @@ -169,25 +172,25 @@ public class AppActor extends RuleChainManagerActor { getOrCreateTenantActor(msg.getTenantId()).tell(msg, ActorRef.noSender()); } - private void processDeviceMsg(DeviceToDeviceActorMsg deviceToDeviceActorMsg) { - TenantId tenantId = deviceToDeviceActorMsg.getTenantId(); - ActorRef tenantActor = getOrCreateTenantActor(tenantId); - if (deviceToDeviceActorMsg.getPayload().getMsgType().requiresRulesProcessing()) { -// tenantActor.tell(new RuleChainDeviceMsg(deviceToDeviceActorMsg, ruleManager.getRuleChain(this.context())), context().self()); - } else { - tenantActor.tell(deviceToDeviceActorMsg, context().self()); - } - } - private ActorRef getOrCreateTenantActor(TenantId tenantId) { - return tenantActors.computeIfAbsent(tenantId, k -> context().actorOf(Props.create(new TenantActor.ActorCreator(systemContext, tenantId)) - .withDispatcher(DefaultActorService.CORE_DISPATCHER_NAME), tenantId.toString())); + return tenantActors.computeIfAbsent(tenantId, k -> { + log.debug("[{}] Creating tenant actor.", tenantId); + ActorRef tenantActor = context().actorOf(Props.create(new TenantActor.ActorCreator(systemContext, tenantId)) + .withDispatcher(DefaultActorService.CORE_DISPATCHER_NAME), tenantId.toString()); + context().watch(tenantActor); + log.debug("[{}] Created tenant actor: {}.", tenantId, tenantActor); + return tenantActor; + }); } - private void processTermination(Terminated message) { + @Override + protected void processTermination(Terminated message) { ActorRef terminated = message.actor(); if (terminated instanceof LocalActorRef) { - logger.debug("Removed actor: {}", terminated); + boolean removed = tenantActors.inverse().remove(terminated) != null; + if (removed) { + log.debug("[{}] Removed actor:", terminated); + } } else { throw new IllegalStateException("Remote actors are not supported!"); } @@ -201,20 +204,17 @@ public class AppActor extends RuleChainManagerActor { } @Override - public AppActor create() throws Exception { + public AppActor create() { return new AppActor(context); } } - private final SupervisorStrategy strategy = new OneForOneStrategy(3, Duration.create("1 minute"), new Function() { - @Override - public Directive apply(Throwable t) { - logger.error(t, "Unknown failure"); - if (t instanceof RuntimeException) { - return SupervisorStrategy.restart(); - } else { - return SupervisorStrategy.stop(); - } + private final SupervisorStrategy strategy = new OneForOneStrategy(3, Duration.create("1 minute"), t -> { + log.warn("Unknown failure", t); + if (t instanceof RuntimeException) { + return SupervisorStrategy.restart(); + } else { + return SupervisorStrategy.stop(); } }); } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java index 99d004574e..f53a410d5f 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActor.java @@ -15,43 +15,44 @@ */ package org.thingsboard.server.actors.device; -import akka.event.Logging; -import akka.event.LoggingAdapter; import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; import org.thingsboard.rule.engine.api.msg.DeviceNameOrTypeUpdateMsg; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.service.ContextAwareActor; -import org.thingsboard.server.actors.service.ContextBasedCreator; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorClientSideRpcTimeoutMsg; -import org.thingsboard.server.common.msg.timeout.DeviceActorQueueTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; import org.thingsboard.server.service.rpc.ToServerRpcResponseActorMsg; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; public class DeviceActor extends ContextAwareActor { - private final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); - private final DeviceActorMessageProcessor processor; - private DeviceActor(ActorSystemContext systemContext, TenantId tenantId, DeviceId deviceId) { + DeviceActor(ActorSystemContext systemContext, TenantId tenantId, DeviceId deviceId) { super(systemContext); - this.processor = new DeviceActorMessageProcessor(systemContext, logger, tenantId, deviceId); + this.processor = new DeviceActorMessageProcessor(systemContext, tenantId, deviceId); + } + + @Override + public void preStart() { + log.debug("[{}][{}] Starting device actor.", processor.tenantId, processor.deviceId); + try { + processor.initSessionTimeout(context()); + log.debug("[{}][{}] Device actor started.", processor.tenantId, processor.deviceId); + } catch (Exception e) { + log.warn("[{}][{}] Unknown failure", processor.tenantId, processor.deviceId, e); + } } @Override protected boolean process(TbActorMsg msg) { switch (msg.getMsgType()) { - case CLUSTER_EVENT_MSG: - processor.processClusterEventMsg((ClusterEventMsg) msg); - break; - case DEVICE_SESSION_TO_DEVICE_ACTOR_MSG: - processor.process(context(), (DeviceToDeviceActorMsg) msg); + case TRANSPORT_TO_DEVICE_ACTOR_MSG: + processor.process(context(), (TransportToDeviceActorMsgWrapper) msg); break; case DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG: processor.processAttributesUpdate(context(), (DeviceAttributesEventNotificationMsg) msg); @@ -74,11 +75,8 @@ public class DeviceActor extends ContextAwareActor { case DEVICE_ACTOR_CLIENT_SIDE_RPC_TIMEOUT_MSG: processor.processClientSideRpcTimeout(context(), (DeviceActorClientSideRpcTimeoutMsg) msg); break; - case DEVICE_ACTOR_QUEUE_TIMEOUT_MSG: - processor.processQueueTimeout(context(), (DeviceActorQueueTimeoutMsg) msg); - break; - case RULE_ENGINE_QUEUE_PUT_ACK_MSG: - processor.processQueueAck(context(), (RuleEngineQueuePutAckMsg) msg); + case SESSION_TIMEOUT_MSG: + processor.checkSessionsTimeout(); break; default: return false; @@ -86,22 +84,4 @@ public class DeviceActor extends ContextAwareActor { return true; } - public static class ActorCreator extends ContextBasedCreator { - private static final long serialVersionUID = 1L; - - private final TenantId tenantId; - private final DeviceId deviceId; - - public ActorCreator(ActorSystemContext context, TenantId tenantId, DeviceId deviceId) { - super(context); - this.tenantId = tenantId; - this.deviceId = deviceId; - } - - @Override - public DeviceActor create() throws Exception { - return new DeviceActor(context, tenantId, deviceId); - } - } - } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java new file mode 100644 index 0000000000..18aa9268bd --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/actors/device/DeviceActorCreator.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.actors.device; + +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.service.ContextBasedCreator; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; + +public class DeviceActorCreator extends ContextBasedCreator { + private static final long serialVersionUID = 1L; + + private final TenantId tenantId; + private final DeviceId deviceId; + + public DeviceActorCreator(ActorSystemContext context, TenantId tenantId, DeviceId deviceId) { + super(context); + this.tenantId = tenantId; + this.deviceId = deviceId; + } + + @Override + public DeviceActor create() { + return new DeviceActor(context, tenantId, deviceId); + } +} 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 a2ea0487cf..41d1ea08c6 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 @@ -16,7 +16,6 @@ package org.thingsboard.server.actors.device; import akka.actor.ActorContext; -import akka.actor.ActorRef; import akka.event.LoggingAdapter; import com.datastax.driver.core.utils.UUIDs; import com.google.common.util.concurrent.FutureCallback; @@ -25,6 +24,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RpcError; import org.thingsboard.rule.engine.api.msg.DeviceAttributesEventNotificationMsg; import org.thingsboard.rule.engine.api.msg.DeviceNameOrTypeUpdateMsg; @@ -33,7 +33,6 @@ import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.SessionId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKey; import org.thingsboard.server.common.data.kv.AttributeKvEntry; @@ -43,37 +42,34 @@ import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.core.ActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.core.AttributesUpdateNotification; -import org.thingsboard.server.common.msg.core.AttributesUpdateRequest; -import org.thingsboard.server.common.msg.core.BasicActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.core.BasicCommandAckResponse; -import org.thingsboard.server.common.msg.core.BasicGetAttributesResponse; -import org.thingsboard.server.common.msg.core.BasicStatusCodeResponse; -import org.thingsboard.server.common.msg.core.GetAttributesRequest; -import org.thingsboard.server.common.msg.core.RuleEngineError; -import org.thingsboard.server.common.msg.core.RuleEngineErrorMsg; -import org.thingsboard.server.common.msg.core.SessionCloseMsg; -import org.thingsboard.server.common.msg.core.SessionCloseNotification; -import org.thingsboard.server.common.msg.core.SessionOpenMsg; -import org.thingsboard.server.common.msg.core.TelemetryUploadRequest; -import org.thingsboard.server.common.msg.core.ToDeviceRpcRequestMsg; -import org.thingsboard.server.common.msg.core.ToDeviceRpcResponseMsg; -import org.thingsboard.server.common.msg.core.ToServerRpcRequestMsg; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; -import org.thingsboard.server.common.msg.kv.BasicAttributeKVMsg; import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorClientSideRpcTimeoutMsg; -import org.thingsboard.server.common.msg.timeout.DeviceActorQueueTimeoutMsg; import org.thingsboard.server.common.msg.timeout.DeviceActorServerSideRpcTimeoutMsg; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueProto; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueType; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionCloseNotificationProto; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEvent; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToRPCMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvListProto; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; import org.thingsboard.server.service.rpc.FromDeviceRpcResponse; import org.thingsboard.server.service.rpc.ToDeviceRpcRequestActorMsg; import org.thingsboard.server.service.rpc.ToServerRpcResponseActorMsg; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; import javax.annotation.Nullable; import java.util.ArrayList; @@ -87,24 +83,22 @@ import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.concurrent.TimeoutException; import java.util.function.Consumer; -import java.util.function.Predicate; import java.util.stream.Collectors; /** * @author Andrew Shvayka */ -public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { - - private final TenantId tenantId; - private final DeviceId deviceId; - private final Map sessions; - private final Map attributeSubscriptions; - private final Map rpcSubscriptions; +@Slf4j +class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcessor { + + final TenantId tenantId; + final DeviceId deviceId; + private final Map sessions; + private final Map attributeSubscriptions; + private final Map rpcSubscriptions; private final Map toDeviceRpcPendingMap; private final Map toServerRpcPendingMap; - private final Map pendingMsgs; private final Gson gson = new Gson(); private final JsonParser jsonParser = new JsonParser(); @@ -114,8 +108,8 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso private String deviceType; private TbMsgMetaData defaultMetaData; - DeviceActorMessageProcessor(ActorSystemContext systemContext, LoggingAdapter logger, TenantId tenantId, DeviceId deviceId) { - super(systemContext, logger); + DeviceActorMessageProcessor(ActorSystemContext systemContext, TenantId tenantId, DeviceId deviceId) { + super(systemContext); this.tenantId = tenantId; this.deviceId = deviceId; this.sessions = new LinkedHashMap<>(); @@ -123,8 +117,8 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso this.rpcSubscriptions = new HashMap<>(); this.toDeviceRpcPendingMap = new HashMap<>(); this.toServerRpcPendingMap = new HashMap<>(); - this.pendingMsgs = new HashMap<>(); initAttributes(); + restoreSessions(); } private void initAttributes() { @@ -139,41 +133,36 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso void processRpcRequest(ActorContext context, ToDeviceRpcRequestActorMsg msg) { ToDeviceRpcRequest request = msg.getMsg(); ToDeviceRpcRequestBody body = request.getBody(); - ToDeviceRpcRequestMsg rpcRequest = new ToDeviceRpcRequestMsg( - rpcSeq++, - body.getMethod(), - body.getParams() - ); + ToDeviceRpcRequestMsg rpcRequest = ToDeviceRpcRequestMsg.newBuilder().setRequestId( + rpcSeq++).setMethodName(body.getMethod()).setParams(body.getParams()).build(); long timeout = request.getExpirationTime() - System.currentTimeMillis(); if (timeout <= 0) { - logger.debug("[{}][{}] Ignoring message due to exp time reached", deviceId, request.getId(), request.getExpirationTime()); + log.debug("[{}][{}] Ignoring message due to exp time reached, {}", deviceId, request.getId(), request.getExpirationTime()); return; } boolean sent = rpcSubscriptions.size() > 0; - Set syncSessionSet = new HashSet<>(); - rpcSubscriptions.entrySet().forEach(sub -> { - ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(rpcRequest, sub.getKey()); - sendMsgToSessionActor(response, sub.getValue().getServer()); - if (SessionType.SYNC == sub.getValue().getType()) { - syncSessionSet.add(sub.getKey()); + Set syncSessionSet = new HashSet<>(); + rpcSubscriptions.forEach((key, value) -> { + sendToTransport(rpcRequest, key, value.getNodeId()); + if (TransportProtos.SessionType.SYNC == value.getType()) { + syncSessionSet.add(key); } }); syncSessionSet.forEach(rpcSubscriptions::remove); if (request.isOneway() && sent) { - logger.debug("[{}] Rpc command response sent [{}]!", deviceId, request.getId()); - systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(msg.getMsg().getId(), msg.getServerAddress(), null, null)); + log.debug("[{}] Rpc command response sent [{}]!", deviceId, request.getId()); + systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(msg.getMsg().getId(), null, null)); } else { registerPendingRpcRequest(context, msg, sent, rpcRequest, timeout); } if (sent) { - logger.debug("[{}] RPC request {} is sent!", deviceId, request.getId()); + log.debug("[{}] RPC request {} is sent!", deviceId, request.getId()); } else { - logger.debug("[{}] RPC request {} is NOT sent!", deviceId, request.getId()); + log.debug("[{}] RPC request {} is NOT sent!", deviceId, request.getId()); } - } private void registerPendingRpcRequest(ActorContext context, ToDeviceRpcRequestActorMsg msg, boolean sent, ToDeviceRpcRequestMsg rpcRequest, long timeout) { @@ -185,101 +174,82 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso void processServerSideRpcTimeout(ActorContext context, DeviceActorServerSideRpcTimeoutMsg msg) { ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(msg.getId()); if (requestMd != null) { - logger.debug("[{}] RPC request [{}] timeout detected!", deviceId, msg.getId()); - systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), - requestMd.getMsg().getServerAddress(), null, requestMd.isSent() ? RpcError.TIMEOUT : RpcError.NO_ACTIVE_CONNECTION)); - } - } - - void processQueueTimeout(ActorContext context, DeviceActorQueueTimeoutMsg msg) { - PendingSessionMsgData data = pendingMsgs.remove(msg.getId()); - if (data != null) { - logger.debug("[{}] Queue put [{}] timeout detected!", deviceId, msg.getId()); - ToDeviceMsg toDeviceMsg = new RuleEngineErrorMsg(data.getSessionMsgType(), RuleEngineError.QUEUE_PUT_TIMEOUT); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, data.getSessionId()), data.getServerAddress()); - } - } - - void processQueueAck(ActorContext context, RuleEngineQueuePutAckMsg msg) { - PendingSessionMsgData data = pendingMsgs.remove(msg.getId()); - if (data != null && data.isReplyOnQueueAck()) { - int remainingAcks = data.getAckMsgCount() - 1; - data.setAckMsgCount(remainingAcks); - logger.debug("[{}] Queue put [{}] ack detected. Remaining acks: {}!", deviceId, msg.getId(), remainingAcks); - if (remainingAcks == 0) { - ToDeviceMsg toDeviceMsg = BasicStatusCodeResponse.onSuccess(data.getSessionMsgType(), data.getRequestId()); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, data.getSessionId()), data.getServerAddress()); - } + log.debug("[{}] RPC request [{}] timeout detected!", deviceId, msg.getId()); + systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), + null, requestMd.isSent() ? RpcError.TIMEOUT : RpcError.NO_ACTIVE_CONNECTION)); } } - private void sendPendingRequests(ActorContext context, SessionId sessionId, SessionType type, Optional server) { + private void sendPendingRequests(ActorContext context, UUID sessionId, SessionInfoProto sessionInfo) { + TransportProtos.SessionType sessionType = getSessionType(sessionId); if (!toDeviceRpcPendingMap.isEmpty()) { - logger.debug("[{}] Pushing {} pending RPC messages to new async session [{}]", deviceId, toDeviceRpcPendingMap.size(), sessionId); - if (type == SessionType.SYNC) { - logger.debug("[{}] Cleanup sync rpc session [{}]", deviceId, sessionId); + log.debug("[{}] Pushing {} pending RPC messages to new async session [{}]", deviceId, toDeviceRpcPendingMap.size(), sessionId); + if (sessionType == TransportProtos.SessionType.SYNC) { + log.debug("[{}] Cleanup sync rpc session [{}]", deviceId, sessionId); rpcSubscriptions.remove(sessionId); } } else { - logger.debug("[{}] No pending RPC messages for new async session [{}]", deviceId, sessionId); + log.debug("[{}] No pending RPC messages for new async session [{}]", deviceId, sessionId); } Set sentOneWayIds = new HashSet<>(); - if (type == SessionType.ASYNC) { - toDeviceRpcPendingMap.entrySet().forEach(processPendingRpc(context, sessionId, server, sentOneWayIds)); + if (sessionType == TransportProtos.SessionType.ASYNC) { + toDeviceRpcPendingMap.entrySet().forEach(processPendingRpc(context, sessionId, sessionInfo.getNodeId(), sentOneWayIds)); } else { - toDeviceRpcPendingMap.entrySet().stream().findFirst().ifPresent(processPendingRpc(context, sessionId, server, sentOneWayIds)); + toDeviceRpcPendingMap.entrySet().stream().findFirst().ifPresent(processPendingRpc(context, sessionId, sessionInfo.getNodeId(), sentOneWayIds)); } sentOneWayIds.forEach(toDeviceRpcPendingMap::remove); } - private Consumer> processPendingRpc(ActorContext context, SessionId sessionId, Optional server, Set sentOneWayIds) { + private Consumer> processPendingRpc(ActorContext context, UUID sessionId, String nodeId, Set sentOneWayIds) { return entry -> { - ToDeviceRpcRequestActorMsg requestActorMsg = entry.getValue().getMsg(); ToDeviceRpcRequest request = entry.getValue().getMsg().getMsg(); ToDeviceRpcRequestBody body = request.getBody(); if (request.isOneway()) { sentOneWayIds.add(entry.getKey()); - systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(request.getId(), requestActorMsg.getServerAddress(), null, null)); + systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(request.getId(), null, null)); } - ToDeviceRpcRequestMsg rpcRequest = new ToDeviceRpcRequestMsg( - entry.getKey(), - body.getMethod(), - body.getParams() - ); - ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(rpcRequest, sessionId); - sendMsgToSessionActor(response, server); + ToDeviceRpcRequestMsg rpcRequest = ToDeviceRpcRequestMsg.newBuilder().setRequestId( + entry.getKey()).setMethodName(body.getMethod()).setParams(body.getParams()).build(); + sendToTransport(rpcRequest, sessionId, nodeId); }; } - void process(ActorContext context, DeviceToDeviceActorMsg msg) { - processSubscriptionCommands(context, msg); - processRpcResponses(context, msg); - processSessionStateMsgs(msg); - - SessionMsgType sessionMsgType = msg.getPayload().getMsgType(); - if (sessionMsgType.requiresRulesProcessing()) { - switch (sessionMsgType) { - case GET_ATTRIBUTES_REQUEST: - handleGetAttributesRequest(msg); - break; - case POST_ATTRIBUTES_REQUEST: - handlePostAttributesRequest(context, msg); - reportActivity(); - break; - case POST_TELEMETRY_REQUEST: - handlePostTelemetryRequest(context, msg); - reportActivity(); - break; - case TO_SERVER_RPC_REQUEST: - handleClientSideRPCRequest(context, msg); - reportActivity(); - break; - } + void process(ActorContext context, TransportToDeviceActorMsgWrapper wrapper) { + TransportToDeviceActorMsg msg = wrapper.getMsg(); + if (msg.hasSessionEvent()) { + processSessionStateMsgs(msg.getSessionInfo(), msg.getSessionEvent()); + } + if (msg.hasSubscribeToAttributes()) { + processSubscriptionCommands(context, msg.getSessionInfo(), msg.getSubscribeToAttributes()); + } + if (msg.hasSubscribeToRPC()) { + processSubscriptionCommands(context, msg.getSessionInfo(), msg.getSubscribeToRPC()); + } + if (msg.hasPostAttributes()) { + handlePostAttributesRequest(context, msg.getSessionInfo(), msg.getPostAttributes()); + reportLogicalDeviceActivity(); + } + if (msg.hasPostTelemetry()) { + handlePostTelemetryRequest(context, msg.getSessionInfo(), msg.getPostTelemetry()); + reportLogicalDeviceActivity(); + } + if (msg.hasGetAttributes()) { + handleGetAttributesRequest(context, msg.getSessionInfo(), msg.getGetAttributes()); + } + if (msg.hasToDeviceRPCCallResponse()) { + processRpcResponses(context, msg.getSessionInfo(), msg.getToDeviceRPCCallResponse()); + } + if (msg.hasToServerRPCCallRequest()) { + handleClientSideRPCRequest(context, msg.getSessionInfo(), msg.getToServerRPCCallRequest()); + reportLogicalDeviceActivity(); + } + if (msg.hasSubscriptionInfo()) { + handleSessionActivity(context, msg.getSessionInfo(), msg.getSubscriptionInfo()); } } - private void reportActivity() { + private void reportLogicalDeviceActivity() { systemContext.getDeviceStateService().onDeviceActivity(deviceId); } @@ -291,27 +261,27 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso systemContext.getDeviceStateService().onDeviceDisconnect(deviceId); } - private void handleGetAttributesRequest(DeviceToDeviceActorMsg src) { - GetAttributesRequest request = (GetAttributesRequest) src.getPayload(); - ListenableFuture> clientAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.CLIENT_SCOPE, request.getClientAttributeNames()); - ListenableFuture> sharedAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.SHARED_SCOPE, request.getSharedAttributeNames()); - + private void handleGetAttributesRequest(ActorContext context, SessionInfoProto sessionInfo, GetAttributeRequestMsg request) { + ListenableFuture> clientAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.CLIENT_SCOPE, toOptionalSet(request.getClientAttributeNamesList())); + ListenableFuture> sharedAttributesFuture = getAttributeKvEntries(deviceId, DataConstants.SHARED_SCOPE, toOptionalSet(request.getSharedAttributeNamesList())); + int requestId = request.getRequestId(); Futures.addCallback(Futures.allAsList(Arrays.asList(clientAttributesFuture, sharedAttributesFuture)), new FutureCallback>>() { @Override public void onSuccess(@Nullable List> result) { - BasicGetAttributesResponse response = BasicGetAttributesResponse.onSuccess(request.getMsgType(), - request.getRequestId(), BasicAttributeKVMsg.from(result.get(0), result.get(1))); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(response, src.getSessionId()), src.getServerAddress()); + GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() + .setRequestId(requestId) + .addAllClientAttributeList(toTsKvProtos(result.get(0))) + .addAllSharedAttributeList(toTsKvProtos(result.get(1))) + .build(); + sendToTransport(responseMsg, sessionInfo); } @Override public void onFailure(Throwable t) { - if (t instanceof Exception) { - ToDeviceMsg toDeviceMsg = BasicStatusCodeResponse.onError(SessionMsgType.GET_ATTRIBUTES_REQUEST, request.getRequestId(), (Exception) t); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, src.getSessionId()), src.getServerAddress()); - } else { - logger.error("[{}] Failed to process attributes request", deviceId, t); - } + GetAttributeResponseMsg responseMsg = GetAttributeResponseMsg.newBuilder() + .setError(t.getMessage()) + .build(); + sendToTransport(responseMsg, sessionInfo); } }); } @@ -328,220 +298,221 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso } } - private void handlePostAttributesRequest(ActorContext context, DeviceToDeviceActorMsg src) { - AttributesUpdateRequest request = (AttributesUpdateRequest) src.getPayload(); - - JsonObject json = new JsonObject(); - for (AttributeKvEntry kv : request.getAttributes()) { - kv.getBooleanValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getLongValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getDoubleValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getStrValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - } - - TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.POST_ATTRIBUTES_REQUEST.name(), deviceId, defaultMetaData.copy(), TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); - PendingSessionMsgData msgData = new PendingSessionMsgData(src.getSessionId(), src.getServerAddress(), - SessionMsgType.POST_ATTRIBUTES_REQUEST, request.getRequestId(), true, 1); - pushToRuleEngineWithTimeout(context, tbMsg, msgData); + private void handlePostAttributesRequest(ActorContext context, SessionInfoProto sessionInfo, PostAttributeMsg postAttributes) { + JsonObject json = getJsonObject(postAttributes.getKvList()); + TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.POST_ATTRIBUTES_REQUEST.name(), deviceId, defaultMetaData.copy(), + TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); + pushToRuleEngine(context, tbMsg); } - private void handlePostTelemetryRequest(ActorContext context, DeviceToDeviceActorMsg src) { - TelemetryUploadRequest request = (TelemetryUploadRequest) src.getPayload(); - - Map> tsData = request.getData(); - - PendingSessionMsgData msgData = new PendingSessionMsgData(src.getSessionId(), src.getServerAddress(), - SessionMsgType.POST_TELEMETRY_REQUEST, request.getRequestId(), true, tsData.size()); - - for (Map.Entry> entry : tsData.entrySet()) { - JsonObject json = new JsonObject(); - for (KvEntry kv : entry.getValue()) { - kv.getBooleanValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getLongValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getDoubleValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - kv.getStrValue().ifPresent(v -> json.addProperty(kv.getKey(), v)); - } + private void handlePostTelemetryRequest(ActorContext context, SessionInfoProto sessionInfo, PostTelemetryMsg postTelemetry) { + for (TsKvListProto tsKv : postTelemetry.getTsKvListList()) { + JsonObject json = getJsonObject(tsKv.getKvList()); TbMsgMetaData metaData = defaultMetaData.copy(); - metaData.putValue("ts", entry.getKey() + ""); + metaData.putValue("ts", tsKv.getTs() + ""); TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.POST_TELEMETRY_REQUEST.name(), deviceId, metaData, TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); - pushToRuleEngineWithTimeout(context, tbMsg, msgData); + pushToRuleEngine(context, tbMsg); } } - private void handleClientSideRPCRequest(ActorContext context, DeviceToDeviceActorMsg src) { - ToServerRpcRequestMsg request = (ToServerRpcRequestMsg) src.getPayload(); - + private void handleClientSideRPCRequest(ActorContext context, SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg request) { + UUID sessionId = getSessionId(sessionInfo); JsonObject json = new JsonObject(); - json.addProperty("method", request.getMethod()); + json.addProperty("method", request.getMethodName()); json.add("params", jsonParser.parse(request.getParams())); TbMsgMetaData requestMetaData = defaultMetaData.copy(); requestMetaData.putValue("requestId", Integer.toString(request.getRequestId())); TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), SessionMsgType.TO_SERVER_RPC_REQUEST.name(), deviceId, requestMetaData, TbMsgDataType.JSON, gson.toJson(json), null, null, 0L); - PendingSessionMsgData msgData = new PendingSessionMsgData(src.getSessionId(), src.getServerAddress(), SessionMsgType.TO_SERVER_RPC_REQUEST, request.getRequestId(), false, 1); - pushToRuleEngineWithTimeout(context, tbMsg, msgData); + context.parent().tell(new DeviceActorToRuleEngineMsg(context.self(), tbMsg), context.self()); scheduleMsgWithDelay(context, new DeviceActorClientSideRpcTimeoutMsg(request.getRequestId(), systemContext.getClientSideRpcTimeout()), systemContext.getClientSideRpcTimeout()); - toServerRpcPendingMap.put(request.getRequestId(), new ToServerRpcRequestMetadata(src.getSessionId(), src.getSessionType(), src.getServerAddress())); + toServerRpcPendingMap.put(request.getRequestId(), new ToServerRpcRequestMetadata(sessionId, getSessionType(sessionId), sessionInfo.getNodeId())); + } + + private TransportProtos.SessionType getSessionType(UUID sessionId) { + return sessions.containsKey(sessionId) ? TransportProtos.SessionType.ASYNC : TransportProtos.SessionType.SYNC; } - public void processClientSideRpcTimeout(ActorContext context, DeviceActorClientSideRpcTimeoutMsg msg) { + void processClientSideRpcTimeout(ActorContext context, DeviceActorClientSideRpcTimeoutMsg msg) { ToServerRpcRequestMetadata data = toServerRpcPendingMap.remove(msg.getId()); if (data != null) { - logger.debug("[{}] Client side RPC request [{}] timeout detected!", deviceId, msg.getId()); - ToDeviceMsg toDeviceMsg = new RuleEngineErrorMsg(SessionMsgType.TO_SERVER_RPC_REQUEST, RuleEngineError.TIMEOUT); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(toDeviceMsg, data.getSessionId()), data.getServer()); + log.debug("[{}] Client side RPC request [{}] timeout detected!", deviceId, msg.getId()); + sendToTransport(TransportProtos.ToServerRpcResponseMsg.newBuilder() + .setRequestId(msg.getId()).setError("timeout").build() + , data.getSessionId(), data.getNodeId()); } } void processToServerRPCResponse(ActorContext context, ToServerRpcResponseActorMsg msg) { - ToServerRpcRequestMetadata data = toServerRpcPendingMap.remove(msg.getMsg().getRequestId()); + int requestId = msg.getMsg().getRequestId(); + ToServerRpcRequestMetadata data = toServerRpcPendingMap.remove(requestId); if (data != null) { - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(msg.getMsg(), data.getSessionId()), data.getServer()); + log.debug("[{}] Pushing reply to [{}][{}]!", deviceId, data.getNodeId(), data.getSessionId()); + sendToTransport(TransportProtos.ToServerRpcResponseMsg.newBuilder() + .setRequestId(requestId).setPayload(msg.getMsg().getData()).build() + , data.getSessionId(), data.getNodeId()); + } else { + log.debug("[{}][{}] Pending RPC request to server not found!", deviceId, requestId); } } - private void pushToRuleEngineWithTimeout(ActorContext context, TbMsg tbMsg, PendingSessionMsgData pendingMsgData) { - SessionMsgType sessionMsgType = pendingMsgData.getSessionMsgType(); - int requestId = pendingMsgData.getRequestId(); - if (systemContext.isQueuePersistenceEnabled()) { - pendingMsgs.put(tbMsg.getId(), pendingMsgData); - scheduleMsgWithDelay(context, new DeviceActorQueueTimeoutMsg(tbMsg.getId(), systemContext.getQueuePersistenceTimeout()), systemContext.getQueuePersistenceTimeout()); - } else { - ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(BasicStatusCodeResponse.onSuccess(sessionMsgType, requestId), pendingMsgData.getSessionId()); - sendMsgToSessionActor(response, pendingMsgData.getServerAddress()); - } + private void pushToRuleEngine(ActorContext context, TbMsg tbMsg) { context.parent().tell(new DeviceActorToRuleEngineMsg(context.self(), tbMsg), context.self()); } void processAttributesUpdate(ActorContext context, DeviceAttributesEventNotificationMsg msg) { if (attributeSubscriptions.size() > 0) { - ToDeviceMsg notification = null; + boolean hasNotificationData = false; + AttributeUpdateNotificationMsg.Builder notification = AttributeUpdateNotificationMsg.newBuilder(); if (msg.isDeleted()) { - List sharedKeys = msg.getDeletedKeys().stream() + List sharedKeys = msg.getDeletedKeys().stream() .filter(key -> DataConstants.SHARED_SCOPE.equals(key.getScope())) + .map(AttributeKey::getAttributeKey) .collect(Collectors.toList()); - notification = new AttributesUpdateNotification(BasicAttributeKVMsg.fromDeleted(sharedKeys)); + if (!sharedKeys.isEmpty()) { + notification.addAllSharedDeleted(sharedKeys); + hasNotificationData = true; + } } else { if (DataConstants.SHARED_SCOPE.equals(msg.getScope())) { List attributes = new ArrayList<>(msg.getValues()); if (attributes.size() > 0) { - notification = new AttributesUpdateNotification(BasicAttributeKVMsg.fromShared(attributes)); + List sharedUpdated = msg.getValues().stream().map(this::toTsKvProto) + .collect(Collectors.toList()); + if (!sharedUpdated.isEmpty()) { + notification.addAllSharedUpdated(sharedUpdated); + hasNotificationData = true; + } } else { - logger.debug("[{}] No public server side attributes changed!", deviceId); + log.debug("[{}] No public server side attributes changed!", deviceId); } } } - if (notification != null) { - ToDeviceMsg finalNotification = notification; + if (hasNotificationData) { + AttributeUpdateNotificationMsg finalNotification = notification.build(); attributeSubscriptions.entrySet().forEach(sub -> { - ActorSystemToDeviceSessionActorMsg response = new BasicActorSystemToDeviceSessionActorMsg(finalNotification, sub.getKey()); - sendMsgToSessionActor(response, sub.getValue().getServer()); + sendToTransport(finalNotification, sub.getKey(), sub.getValue().getNodeId()); }); } } else { - logger.debug("[{}] No registered attributes subscriptions to process!", deviceId); + log.debug("[{}] No registered attributes subscriptions to process!", deviceId); } } - private void processRpcResponses(ActorContext context, DeviceToDeviceActorMsg msg) { - SessionId sessionId = msg.getSessionId(); - FromDeviceMsg inMsg = msg.getPayload(); - if (inMsg.getMsgType() == SessionMsgType.TO_DEVICE_RPC_RESPONSE) { - logger.debug("[{}] Processing rpc command response [{}]", deviceId, sessionId); - ToDeviceRpcResponseMsg responseMsg = (ToDeviceRpcResponseMsg) inMsg; - ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); - boolean success = requestMd != null; - if (success) { - systemContext.getDeviceRpcService().processRpcResponseFromDevice(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), - requestMd.getMsg().getServerAddress(), responseMsg.getData(), null)); - } else { - logger.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); - } - if (msg.getSessionType() == SessionType.SYNC) { - BasicCommandAckResponse response = success - ? BasicCommandAckResponse.onSuccess(SessionMsgType.TO_DEVICE_RPC_REQUEST, responseMsg.getRequestId()) - : BasicCommandAckResponse.onError(SessionMsgType.TO_DEVICE_RPC_REQUEST, responseMsg.getRequestId(), new TimeoutException()); - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(response, msg.getSessionId()), msg.getServerAddress()); - } + private void processRpcResponses(ActorContext context, SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg responseMsg) { + UUID sessionId = getSessionId(sessionInfo); + log.debug("[{}] Processing rpc command response [{}]", deviceId, sessionId); + ToDeviceRpcRequestMetadata requestMd = toDeviceRpcPendingMap.remove(responseMsg.getRequestId()); + boolean success = requestMd != null; + if (success) { + systemContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromDeviceActor(new FromDeviceRpcResponse(requestMd.getMsg().getMsg().getId(), + responseMsg.getPayload(), null)); + } else { + log.debug("[{}] Rpc command response [{}] is stale!", deviceId, responseMsg.getRequestId()); } } - void processClusterEventMsg(ClusterEventMsg msg) { - if (!msg.isAdded()) { - logger.debug("[{}] Clearing attributes/rpc subscription for server [{}]", deviceId, msg.getServerAddress()); - Predicate> filter = e -> e.getValue().getServer() - .map(serverAddress -> serverAddress.equals(msg.getServerAddress())).orElse(false); - attributeSubscriptions.entrySet().removeIf(filter); - rpcSubscriptions.entrySet().removeIf(filter); + private void processSubscriptionCommands(ActorContext context, SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg subscribeCmd) { + UUID sessionId = getSessionId(sessionInfo); + if (subscribeCmd.getUnsubscribe()) { + log.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId); + attributeSubscriptions.remove(sessionId); + } else { + SessionInfoMetaData sessionMD = sessions.get(sessionId); + if (sessionMD == null) { + sessionMD = new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId())); + } + sessionMD.setSubscribedToAttributes(true); + log.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId); + attributeSubscriptions.put(sessionId, sessionMD.getSessionInfo()); + dumpSessions(); } } - private void processSubscriptionCommands(ActorContext context, DeviceToDeviceActorMsg msg) { - SessionId sessionId = msg.getSessionId(); - SessionType sessionType = msg.getSessionType(); - FromDeviceMsg inMsg = msg.getPayload(); - if (inMsg.getMsgType() == SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST) { - logger.debug("[{}] Registering attributes subscription for session [{}]", deviceId, sessionId); - attributeSubscriptions.put(sessionId, new SessionInfo(sessionType, msg.getServerAddress())); - } else if (inMsg.getMsgType() == SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST) { - logger.debug("[{}] Canceling attributes subscription for session [{}]", deviceId, sessionId); - attributeSubscriptions.remove(sessionId); - } else if (inMsg.getMsgType() == SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST) { - logger.debug("[{}] Registering rpc subscription for session [{}][{}]", deviceId, sessionId, sessionType); - rpcSubscriptions.put(sessionId, new SessionInfo(sessionType, msg.getServerAddress())); - sendPendingRequests(context, sessionId, sessionType, msg.getServerAddress()); - } else if (inMsg.getMsgType() == SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST) { - logger.debug("[{}] Canceling rpc subscription for session [{}][{}]", deviceId, sessionId, sessionType); + private UUID getSessionId(SessionInfoProto sessionInfo) { + return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + } + + private void processSubscriptionCommands(ActorContext context, SessionInfoProto sessionInfo, SubscribeToRPCMsg subscribeCmd) { + UUID sessionId = getSessionId(sessionInfo); + if (subscribeCmd.getUnsubscribe()) { + log.debug("[{}] Canceling rpc subscription for session [{}]", deviceId, sessionId); rpcSubscriptions.remove(sessionId); + } else { + SessionInfoMetaData sessionMD = sessions.get(sessionId); + if (sessionMD == null) { + sessionMD = new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.SYNC, sessionInfo.getNodeId())); + } + sessionMD.setSubscribedToRPC(true); + log.debug("[{}] Registering rpc subscription for session [{}]", deviceId, sessionId); + rpcSubscriptions.put(sessionId, sessionMD.getSessionInfo()); + sendPendingRequests(context, sessionId, sessionInfo); + dumpSessions(); } } - private void processSessionStateMsgs(DeviceToDeviceActorMsg msg) { - SessionId sessionId = msg.getSessionId(); - FromDeviceMsg inMsg = msg.getPayload(); - if (inMsg instanceof SessionOpenMsg) { - logger.debug("[{}] Processing new session [{}]", deviceId, sessionId); + private void processSessionStateMsgs(SessionInfoProto sessionInfo, SessionEventMsg msg) { + UUID sessionId = getSessionId(sessionInfo); + if (msg.getEvent() == SessionEvent.OPEN) { + if (sessions.containsKey(sessionId)) { + log.debug("[{}] Received duplicate session open event [{}]", deviceId, sessionId); + return; + } + log.debug("[{}] Processing new session [{}]", deviceId, sessionId); if (sessions.size() >= systemContext.getMaxConcurrentSessionsPerDevice()) { - SessionId sessionIdToRemove = sessions.keySet().stream().findFirst().orElse(null); + UUID sessionIdToRemove = sessions.keySet().stream().findFirst().orElse(null); if (sessionIdToRemove != null) { - closeSession(sessionIdToRemove, sessions.remove(sessionIdToRemove)); + notifyTransportAboutClosedSession(sessionIdToRemove, sessions.remove(sessionIdToRemove)); } } - sessions.put(sessionId, new SessionInfo(SessionType.ASYNC, msg.getServerAddress())); + sessions.put(sessionId, new SessionInfoMetaData(new SessionInfo(TransportProtos.SessionType.ASYNC, sessionInfo.getNodeId()))); if (sessions.size() == 1) { reportSessionOpen(); } - } else if (inMsg instanceof SessionCloseMsg) { - logger.debug("[{}] Canceling subscriptions for closed session [{}]", deviceId, sessionId); + dumpSessions(); + } else if (msg.getEvent() == SessionEvent.CLOSED) { + log.debug("[{}] Canceling subscriptions for closed session [{}]", deviceId, sessionId); sessions.remove(sessionId); attributeSubscriptions.remove(sessionId); rpcSubscriptions.remove(sessionId); if (sessions.isEmpty()) { reportSessionClose(); } + dumpSessions(); } } - private void sendMsgToSessionActor(ActorSystemToDeviceSessionActorMsg response, Optional sessionAddress) { - if (sessionAddress.isPresent()) { - ServerAddress address = sessionAddress.get(); - logger.debug("{} Forwarding msg: {}", address, response); - systemContext.getRpcService().tell(systemContext.getEncodingService() - .convertToProtoDataMessage(sessionAddress.get(), response)); - } else { - systemContext.getSessionManagerActor().tell(response, ActorRef.noSender()); + private void handleSessionActivity(ActorContext context, SessionInfoProto sessionInfo, TransportProtos.SubscriptionInfoProto subscriptionInfo) { + UUID sessionId = getSessionId(sessionInfo); + SessionInfoMetaData sessionMD = sessions.get(sessionId); + if (sessionMD != null) { + sessionMD.setLastActivityTime(subscriptionInfo.getLastActivityTime()); + sessionMD.setSubscribedToAttributes(subscriptionInfo.getAttributeSubscription()); + sessionMD.setSubscribedToRPC(subscriptionInfo.getRpcSubscription()); + if (subscriptionInfo.getAttributeSubscription()) { + attributeSubscriptions.putIfAbsent(sessionId, sessionMD.getSessionInfo()); + } + if (subscriptionInfo.getRpcSubscription()) { + rpcSubscriptions.putIfAbsent(sessionId, sessionMD.getSessionInfo()); + } } + dumpSessions(); } void processCredentialsUpdate() { - sessions.forEach(this::closeSession); + sessions.forEach(this::notifyTransportAboutClosedSession); attributeSubscriptions.clear(); rpcSubscriptions.clear(); + dumpSessions(); } - private void closeSession(SessionId sessionId, SessionInfo sessionInfo) { - sendMsgToSessionActor(new BasicActorSystemToDeviceSessionActorMsg(new SessionCloseNotification(), sessionId), sessionInfo.getServer()); + private void notifyTransportAboutClosedSession(UUID sessionId, SessionInfoMetaData sessionMd) { + DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setSessionCloseNotification(SessionCloseNotificationProto.getDefaultInstance()).build(); + systemContext.getRuleEngineTransportService().process(sessionMd.getSessionInfo().getNodeId(), msg); } void processNameOrTypeUpdate(DeviceNameOrTypeUpdateMsg msg) { @@ -552,4 +523,178 @@ public class DeviceActorMessageProcessor extends AbstractContextAwareMsgProcesso this.defaultMetaData.putValue("deviceType", deviceType); } + private JsonObject getJsonObject(List tsKv) { + JsonObject json = new JsonObject(); + for (KeyValueProto kv : tsKv) { + switch (kv.getType()) { + case BOOLEAN_V: + json.addProperty(kv.getKey(), kv.getBoolV()); + break; + case LONG_V: + json.addProperty(kv.getKey(), kv.getLongV()); + break; + case DOUBLE_V: + json.addProperty(kv.getKey(), kv.getDoubleV()); + break; + case STRING_V: + json.addProperty(kv.getKey(), kv.getStringV()); + break; + } + } + return json; + } + + private Optional> toOptionalSet(List strings) { + if (strings == null || strings.isEmpty()) { + return Optional.empty(); + } else { + return Optional.of(new HashSet<>(strings)); + } + } + + private void sendToTransport(GetAttributeResponseMsg responseMsg, SessionInfoProto sessionInfo) { + DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() + .setSessionIdMSB(sessionInfo.getSessionIdMSB()) + .setSessionIdLSB(sessionInfo.getSessionIdLSB()) + .setGetAttributesResponse(responseMsg).build(); + systemContext.getRuleEngineTransportService().process(sessionInfo.getNodeId(), msg); + } + + private void sendToTransport(AttributeUpdateNotificationMsg notificationMsg, UUID sessionId, String nodeId) { + DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setAttributeUpdateNotification(notificationMsg).build(); + systemContext.getRuleEngineTransportService().process(nodeId, msg); + } + + private void sendToTransport(ToDeviceRpcRequestMsg rpcMsg, UUID sessionId, String nodeId) { + DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setToDeviceRequest(rpcMsg).build(); + systemContext.getRuleEngineTransportService().process(nodeId, msg); + } + + private void sendToTransport(TransportProtos.ToServerRpcResponseMsg rpcMsg, UUID sessionId, String nodeId) { + DeviceActorToTransportMsg msg = DeviceActorToTransportMsg.newBuilder() + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setToServerResponse(rpcMsg).build(); + systemContext.getRuleEngineTransportService().process(nodeId, msg); + } + + + private List toTsKvProtos(@Nullable List result) { + List clientAttributes; + if (result == null || result.isEmpty()) { + clientAttributes = Collections.emptyList(); + } else { + clientAttributes = new ArrayList<>(result.size()); + for (AttributeKvEntry attrEntry : result) { + clientAttributes.add(toTsKvProto(attrEntry)); + } + } + return clientAttributes; + } + + private TsKvProto toTsKvProto(AttributeKvEntry attrEntry) { + return TsKvProto.newBuilder().setTs(attrEntry.getLastUpdateTs()) + .setKv(toKeyValueProto(attrEntry)).build(); + } + + private KeyValueProto toKeyValueProto(KvEntry kvEntry) { + KeyValueProto.Builder builder = KeyValueProto.newBuilder(); + builder.setKey(kvEntry.getKey()); + switch (kvEntry.getDataType()) { + case BOOLEAN: + builder.setType(KeyValueType.BOOLEAN_V); + builder.setBoolV(kvEntry.getBooleanValue().get()); + break; + case DOUBLE: + builder.setType(KeyValueType.DOUBLE_V); + builder.setDoubleV(kvEntry.getDoubleValue().get()); + break; + case LONG: + builder.setType(KeyValueType.LONG_V); + builder.setLongV(kvEntry.getLongValue().get()); + break; + case STRING: + builder.setType(KeyValueType.STRING_V); + builder.setStringV(kvEntry.getStrValue().get()); + break; + } + return builder.build(); + } + + private void restoreSessions() { + log.debug("[{}] Restoring sessions from cache", deviceId); + TransportProtos.DeviceSessionsCacheEntry sessionsDump = systemContext.getDeviceSessionCacheService().get(deviceId); + if (sessionsDump.getSerializedSize() == 0) { + log.debug("[{}] No session information found", deviceId); + return; + } + for (TransportProtos.SessionSubscriptionInfoProto sessionSubscriptionInfoProto : sessionsDump.getSessionsList()) { + SessionInfoProto sessionInfoProto = sessionSubscriptionInfoProto.getSessionInfo(); + UUID sessionId = getSessionId(sessionInfoProto); + SessionInfo sessionInfo = new SessionInfo(TransportProtos.SessionType.ASYNC, sessionInfoProto.getNodeId()); + TransportProtos.SubscriptionInfoProto subInfo = sessionSubscriptionInfoProto.getSubscriptionInfo(); + SessionInfoMetaData sessionMD = new SessionInfoMetaData(sessionInfo, subInfo.getLastActivityTime()); + sessions.put(sessionId, sessionMD); + if (subInfo.getAttributeSubscription()) { + attributeSubscriptions.put(sessionId, sessionInfo); + sessionMD.setSubscribedToAttributes(true); + } + if (subInfo.getRpcSubscription()) { + rpcSubscriptions.put(sessionId, sessionInfo); + sessionMD.setSubscribedToRPC(true); + } + log.debug("[{}] Restored session: {}", deviceId, sessionMD); + } + log.debug("[{}] Restored sessions: {}, rpc subscriptions: {}, attribute subscriptions: {}", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size()); + } + + private void dumpSessions() { + log.debug("[{}] Dumping sessions: {}, rpc subscriptions: {}, attribute subscriptions: {} to cache", deviceId, sessions.size(), rpcSubscriptions.size(), attributeSubscriptions.size()); + List sessionsList = new ArrayList<>(sessions.size()); + sessions.forEach((uuid, sessionMD) -> { + if (sessionMD.getSessionInfo().getType() == TransportProtos.SessionType.SYNC) { + return; + } + SessionInfo sessionInfo = sessionMD.getSessionInfo(); + TransportProtos.SubscriptionInfoProto subscriptionInfoProto = TransportProtos.SubscriptionInfoProto.newBuilder() + .setLastActivityTime(sessionMD.getLastActivityTime()) + .setAttributeSubscription(sessionMD.isSubscribedToAttributes()) + .setRpcSubscription(sessionMD.isSubscribedToRPC()).build(); + TransportProtos.SessionInfoProto sessionInfoProto = TransportProtos.SessionInfoProto.newBuilder() + .setSessionIdMSB(uuid.getMostSignificantBits()) + .setSessionIdLSB(uuid.getLeastSignificantBits()) + .setNodeId(sessionInfo.getNodeId()).build(); + sessionsList.add(TransportProtos.SessionSubscriptionInfoProto.newBuilder() + .setSessionInfo(sessionInfoProto) + .setSubscriptionInfo(subscriptionInfoProto).build()); + log.debug("[{}] Dumping session: {}", deviceId, sessionMD); + }); + systemContext.getDeviceSessionCacheService() + .put(deviceId, TransportProtos.DeviceSessionsCacheEntry.newBuilder() + .addAllSessions(sessionsList).build()); + } + + void initSessionTimeout(ActorContext context) { + schedulePeriodicMsgWithDelay(context, SessionTimeoutCheckMsg.instance(), systemContext.getSessionInactivityTimeout(), systemContext.getSessionInactivityTimeout()); + } + + void checkSessionsTimeout() { + long expTime = System.currentTimeMillis() - systemContext.getSessionInactivityTimeout(); + Map sessionsToRemove = sessions.entrySet().stream().filter(kv -> kv.getValue().getLastActivityTime() < expTime).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + sessionsToRemove.forEach((sessionId, sessionMD) -> { + sessions.remove(sessionId); + rpcSubscriptions.remove(sessionId); + attributeSubscriptions.remove(sessionId); + notifyTransportAboutClosedSession(sessionId, sessionMD); + }); + if (!sessionsToRemove.isEmpty()) { + dumpSessions(); + } + } } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java b/application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java deleted file mode 100644 index 23ad966049..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/device/PendingSessionMsgData.java +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.device; - -import lombok.AllArgsConstructor; -import lombok.Data; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -import java.util.Optional; - -/** - * Created by ashvayka on 17.04.18. - */ -@Data -@AllArgsConstructor -public final class PendingSessionMsgData { - - private final SessionId sessionId; - private final Optional serverAddress; - private final SessionMsgType sessionMsgType; - private final int requestId; - private final boolean replyOnQueueAck; - private int ackMsgCount; - -} diff --git a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java index 04c457c92e..fe09077af1 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfo.java @@ -16,10 +16,7 @@ package org.thingsboard.server.actors.device; import lombok.Data; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.session.SessionType; - -import java.util.Optional; +import org.thingsboard.server.gen.transport.TransportProtos.SessionType; /** * @author Andrew Shvayka @@ -27,5 +24,6 @@ import java.util.Optional; @Data public class SessionInfo { private final SessionType type; - private final Optional server; + private final String nodeId; + private long lastActivityTime; } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcRequestMsg.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java similarity index 54% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcRequestMsg.java rename to application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java index d1a1f96451..dd0839486f 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcRequestMsg.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionInfoMetaData.java @@ -13,29 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; +package org.thingsboard.server.actors.device; import lombok.Data; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionType; /** * @author Andrew Shvayka */ @Data -public class ToDeviceRpcRequestMsg implements ToDeviceMsg { +class SessionInfoMetaData { + private final SessionInfo sessionInfo; + private long lastActivityTime; + private boolean subscribedToAttributes; + private boolean subscribedToRPC; - private final int requestId; - private final String method; - private final String params; - - public SessionMsgType getSessionMsgType() { - return SessionMsgType.TO_DEVICE_RPC_REQUEST; + SessionInfoMetaData(SessionInfo sessionInfo) { + this(sessionInfo, System.currentTimeMillis()); } - @Override - public boolean isSuccess() { - return true; + SessionInfoMetaData(SessionInfo sessionInfo, long lastActivityTime) { + this.sessionInfo = sessionInfo; + this.lastActivityTime = lastActivityTime; } } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/RuleEngineQueuePutAckMsg.java b/application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java similarity index 69% rename from application/src/main/java/org/thingsboard/server/actors/device/RuleEngineQueuePutAckMsg.java rename to application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java index 0b6fe54c4a..d9172ae825 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/RuleEngineQueuePutAckMsg.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/SessionTimeoutCheckMsg.java @@ -15,22 +15,25 @@ */ package org.thingsboard.server.actors.device; -import lombok.Data; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; -import java.util.UUID; - /** - * Created by ashvayka on 15.03.18. + * Created by ashvayka on 29.10.18. */ -@Data -public final class RuleEngineQueuePutAckMsg implements TbActorMsg { +public class SessionTimeoutCheckMsg implements TbActorMsg { + + private static final SessionTimeoutCheckMsg INSTANCE = new SessionTimeoutCheckMsg(); - private final UUID id; + private SessionTimeoutCheckMsg() { + } + + public static SessionTimeoutCheckMsg instance() { + return INSTANCE; + } @Override public MsgType getMsgType() { - return MsgType.RULE_ENGINE_QUEUE_PUT_ACK_MSG; + return MsgType.SESSION_TIMEOUT_MSG; } } diff --git a/application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java b/application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java index f82a8c2481..669d94b17d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java +++ b/application/src/main/java/org/thingsboard/server/actors/device/ToServerRpcRequestMetadata.java @@ -16,18 +16,16 @@ package org.thingsboard.server.actors.device; import lombok.Data; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.session.SessionType; +import org.thingsboard.server.gen.transport.TransportProtos; -import java.util.Optional; +import java.util.UUID; /** * @author Andrew Shvayka */ @Data public class ToServerRpcRequestMetadata { - private final SessionId sessionId; - private final SessionType type; - private final Optional server; + private final UUID sessionId; + private final TransportProtos.SessionType type; + private final String nodeId; } diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java b/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java index 14bb636830..760d4a6d96 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/BasicRpcSessionListener.java @@ -32,7 +32,7 @@ public class BasicRpcSessionListener implements GrpcSessionListener { private final ActorRef manager; private final ActorRef self; - public BasicRpcSessionListener(ActorService service, ActorRef manager, ActorRef self) { + BasicRpcSessionListener(ActorService service, ActorRef manager, ActorRef self) { this.service = service; this.manager = manager; this.self = self; @@ -40,7 +40,7 @@ public class BasicRpcSessionListener implements GrpcSessionListener { @Override public void onConnected(GrpcSession session) { - log.info("{} session started -> {}", getType(session), session.getRemoteServer()); + log.info("[{}][{}] session started", session.getRemoteServer(), getType(session)); if (!session.isClient()) { manager.tell(new RpcSessionConnectedMsg(session.getRemoteServer(), session.getSessionId()), self); } @@ -48,21 +48,19 @@ public class BasicRpcSessionListener implements GrpcSessionListener { @Override public void onDisconnected(GrpcSession session) { - log.info("{} session closed -> {}", getType(session), session.getRemoteServer()); + log.info("[{}][{}] session closed", session.getRemoteServer(), getType(session)); manager.tell(new RpcSessionDisconnectedMsg(session.isClient(), session.getRemoteServer()), self); } @Override public void onReceiveClusterGrpcMsg(GrpcSession session, ClusterAPIProtos.ClusterMessage clusterMessage) { - log.trace("{} Service [{}] received session actor msg {}", getType(session), - session.getRemoteServer(), - clusterMessage); + log.trace("Received session actor msg from [{}][{}]: {}", session.getRemoteServer(), getType(session), clusterMessage); service.onReceivedMsg(session.getRemoteServer(), clusterMessage); } @Override public void onError(GrpcSession session, Throwable t) { - log.warn("{} session got error -> {}", getType(session), session.getRemoteServer(), t); + log.warn("[{}][{}] session got error -> {}", session.getRemoteServer(), getType(session), t); manager.tell(new RpcSessionClosedMsg(session.isClient(), session.getRemoteServer()), self); session.close(); } diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java index 9e38c17aff..9fa087e630 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcManagerActor.java @@ -19,6 +19,7 @@ import akka.actor.ActorRef; import akka.actor.Props; import akka.event.Logging; import akka.event.LoggingAdapter; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.service.ContextAwareActor; import org.thingsboard.server.actors.service.ContextBasedCreator; @@ -26,6 +27,7 @@ import org.thingsboard.server.actors.service.DefaultActorService; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; import org.thingsboard.server.gen.cluster.ClusterAPIProtos; import org.thingsboard.server.service.cluster.discovery.ServerInstance; @@ -36,15 +38,13 @@ import java.util.*; */ public class RpcManagerActor extends ContextAwareActor { - private final LoggingAdapter log = Logging.getLogger(getContext().system(), this); - private final Map sessionActors; private final Map> pendingMsgs; private final ServerAddress instance; - public RpcManagerActor(ActorSystemContext systemContext) { + private RpcManagerActor(ActorSystemContext systemContext) { super(systemContext); this.sessionActors = new HashMap<>(); this.pendingMsgs = new HashMap<>(); @@ -54,7 +54,6 @@ public class RpcManagerActor extends ContextAwareActor { .filter(otherServer -> otherServer.getServerAddress().compareTo(instance) > 0) .forEach(otherServer -> onCreateSessionRequest( new RpcSessionCreateRequestMsg(UUID.randomUUID(), otherServer.getServerAddress(), null))); - } @Override @@ -100,24 +99,23 @@ public class RpcManagerActor extends ContextAwareActor { private void onMsg(ClusterAPIProtos.ClusterMessage msg) { if (msg.hasServerAddress()) { - ServerAddress address = new ServerAddress(msg.getServerAddress().getHost(), - msg.getServerAddress().getPort()); + ServerAddress address = new ServerAddress(msg.getServerAddress().getHost(), msg.getServerAddress().getPort(), ServerType.CORE); SessionActorInfo session = sessionActors.get(address); if (session != null) { - log.debug("{} Forwarding msg to session actor", address); + log.debug("{} Forwarding msg to session actor: {}", address, msg); session.getActor().tell(msg, ActorRef.noSender()); } else { - log.debug("{} Storing msg to pending queue", address); + log.debug("{} Storing msg to pending queue: {}", address, msg); Queue queue = pendingMsgs.get(address); if (queue == null) { queue = new LinkedList<>(); pendingMsgs.put(new ServerAddress( - msg.getServerAddress().getHost(), msg.getServerAddress().getPort()), queue); + msg.getServerAddress().getHost(), msg.getServerAddress().getPort(), ServerType.CORE), queue); } queue.add(msg); } } else { - logger.warning("Cluster msg doesn't have set Server Address [{}]", msg); + log.warn("Cluster msg doesn't have server address [{}]", msg); } } @@ -162,9 +160,9 @@ public class RpcManagerActor extends ContextAwareActor { } private void onSessionClose(boolean reconnect, ServerAddress remoteAddress) { - log.debug("[{}] session closed. Should reconnect: {}", remoteAddress, reconnect); + log.info("[{}] session closed. Should reconnect: {}", remoteAddress, reconnect); SessionActorInfo sessionRef = sessionActors.get(remoteAddress); - if (context().sender() != null && context().sender().equals(sessionRef.actor)) { + if (sessionRef != null && context().sender() != null && context().sender().equals(sessionRef.actor)) { sessionActors.remove(remoteAddress); pendingMsgs.remove(remoteAddress); if (reconnect) { @@ -182,18 +180,18 @@ public class RpcManagerActor extends ContextAwareActor { private void register(ServerAddress remoteAddress, UUID uuid, ActorRef sender) { sessionActors.put(remoteAddress, new SessionActorInfo(uuid, sender)); - log.debug("[{}][{}] Registering session actor.", remoteAddress, uuid); + log.info("[{}][{}] Registering session actor.", remoteAddress, uuid); Queue data = pendingMsgs.remove(remoteAddress); if (data != null) { - log.debug("[{}][{}] Forwarding {} pending messages.", remoteAddress, uuid, data.size()); + log.info("[{}][{}] Forwarding {} pending messages.", remoteAddress, uuid, data.size()); data.forEach(msg -> sender.tell(new RpcSessionTellMsg(msg), ActorRef.noSender())); } else { - log.debug("[{}][{}] No pending messages to forward.", remoteAddress, uuid); + log.info("[{}][{}] No pending messages to forward.", remoteAddress, uuid); } } private ActorRef createSessionActor(RpcSessionCreateRequestMsg msg) { - log.debug("[{}] Creating session actor.", msg.getMsgUid()); + log.info("[{}] Creating session actor.", msg.getMsgUid()); ActorRef actor = context().actorOf( Props.create(new RpcSessionActor.ActorCreator(systemContext, msg.getMsgUid())).withDispatcher(DefaultActorService.RPC_DISPATCHER_NAME)); actor.tell(msg, context().self()); @@ -208,7 +206,7 @@ public class RpcManagerActor extends ContextAwareActor { } @Override - public RpcManagerActor create() throws Exception { + public RpcManagerActor create() { return new RpcManagerActor(context); } } diff --git a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcSessionActor.java b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcSessionActor.java index c9cf8696e5..86509ca08e 100644 --- a/application/src/main/java/org/thingsboard/server/actors/rpc/RpcSessionActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/rpc/RpcSessionActor.java @@ -18,6 +18,7 @@ package org.thingsboard.server.actors.rpc; import akka.event.Logging; import akka.event.LoggingAdapter; import io.grpc.Channel; +import io.grpc.ManagedChannel; import io.grpc.ManagedChannelBuilder; import io.grpc.stub.StreamObserver; import org.thingsboard.server.actors.ActorSystemContext; @@ -88,8 +89,8 @@ public class RpcSessionActor extends ContextAwareActor { systemContext.getRpcService().onSessionCreated(msg.getMsgUid(), session.getInputStream()); } else { // Client session - Channel channel = ManagedChannelBuilder.forAddress(remoteServer.getHost(), remoteServer.getPort()).usePlaintext(true).build(); - session = new GrpcSession(remoteServer, listener); + ManagedChannel channel = ManagedChannelBuilder.forAddress(remoteServer.getHost(), remoteServer.getPort()).usePlaintext().build(); + session = new GrpcSession(remoteServer, listener, channel); session.initInputStream(); ClusterRpcServiceGrpc.ClusterRpcServiceStub stub = ClusterRpcServiceGrpc.newStub(channel); diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index 7279347b1c..0baeceabc3 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -17,6 +17,7 @@ package org.thingsboard.server.actors.ruleChain; import akka.actor.ActorRef; import com.datastax.driver.core.utils.UUIDs; +import org.springframework.util.StringUtils; import org.thingsboard.rule.engine.api.ListeningExecutor; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.RuleEngineDeviceRpcRequest; @@ -35,12 +36,15 @@ import org.thingsboard.server.common.data.rpc.ToDeviceRpcRequestBody; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; import org.thingsboard.server.common.msg.rpc.ToDeviceRpcRequest; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; @@ -154,7 +158,7 @@ class DefaultTbContext implements TbContext { @Override public ScriptEngine createJsScriptEngine(String script, String... argNames) { - return new RuleNodeJsScriptEngine(mainCtx.getJsSandbox(), script, argNames); + return new RuleNodeJsScriptEngine(mainCtx.getJsSandbox(), nodeCtx.getSelf().getId(), script, argNames); } @Override @@ -212,6 +216,11 @@ class DefaultTbContext implements TbContext { return mainCtx.getRelationService(); } + @Override + public EntityViewService getEntityViewService() { + return mainCtx.getEntityViewService(); + } + @Override public MailService getMailService() { if (mainCtx.isAllowSystemMailService()) { @@ -226,16 +235,22 @@ class DefaultTbContext implements TbContext { return new RuleEngineRpcService() { @Override public void sendRpcReply(DeviceId deviceId, int requestId, String body) { - mainCtx.getDeviceRpcService().sendRpcReplyToDevice(nodeCtx.getTenantId(), deviceId, requestId, body); + mainCtx.getDeviceRpcService().sendReplyToRpcCallFromDevice(nodeCtx.getTenantId(), deviceId, requestId, body); } @Override public void sendRpcRequest(RuleEngineDeviceRpcRequest src, Consumer consumer) { ToDeviceRpcRequest request = new ToDeviceRpcRequest(src.getRequestUUID(), nodeCtx.getTenantId(), src.getDeviceId(), src.isOneway(), src.getExpirationTime(), new ToDeviceRpcRequestBody(src.getMethod(), src.getBody())); - mainCtx.getDeviceRpcService().processRpcRequestToDevice(request, response -> { + mainCtx.getDeviceRpcService().forwardServerSideRPCRequestToDeviceActor(request, response -> { if (src.isRestApiCall()) { - mainCtx.getDeviceRpcService().processRestAPIRpcResponseFromRuleEngine(response); + ServerAddress requestOriginAddress; + if (!StringUtils.isEmpty(src.getOriginHost())) { + requestOriginAddress = new ServerAddress(src.getOriginHost(), src.getOriginPort(), ServerType.CORE); + } else { + requestOriginAddress = mainCtx.getRoutingService().getCurrentServer(); + } + mainCtx.getDeviceRpcService().processResponseToServerSideRPCRequestFromRuleEngine(requestOriginAddress, response); } consumer.accept(RuleEngineDeviceRpcResponse.builder() .deviceId(src.getDeviceId()) diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java index dbad7c0b66..be393204ed 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActor.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.actors.ruleChain; +import akka.actor.ActorInitializationException; import akka.actor.OneForOneStrategy; import akka.actor.SupervisorStrategy; import org.thingsboard.server.actors.ActorSystemContext; @@ -33,7 +34,7 @@ public class RuleChainActor extends ComponentActor { private static final long DEFAULT_CLUSTER_PARTITION = 0L; @@ -68,59 +69,58 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor(); this.nodeRoutes = new HashMap<>(); this.service = systemContext.getRuleChainService(); + this.ruleChainName = ruleChainId.toString(); } @Override - public void start(ActorContext context) throws Exception { + public String getComponentName() { + return null; + } + + @Override + public void start(ActorContext context) { if (!started) { RuleChain ruleChain = service.findRuleChainById(entityId); + ruleChainName = ruleChain.getName(); List ruleNodeList = service.getRuleChainNodes(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); - reprocess(ruleNodeList); started = true; } else { onUpdate(context); } } - private void reprocess(List ruleNodeList) { - for (RuleNode ruleNode : ruleNodeList) { - for (TbMsg tbMsg : queue.findUnprocessed(tenantId, ruleNode.getId().getId(), systemContext.getQueuePartitionId())) { - pushMsgToNode(nodeActors.get(ruleNode.getId()), tbMsg, ""); - } - } - if (firstNode != null) { - for (TbMsg tbMsg : queue.findUnprocessed(tenantId, entityId.getId(), systemContext.getQueuePartitionId())) { - pushMsgToNode(firstNode, tbMsg, ""); - } - } - } - @Override - public void onUpdate(ActorContext context) throws Exception { + public void onUpdate(ActorContext context) { RuleChain ruleChain = service.findRuleChainById(entityId); + ruleChainName = ruleChain.getName(); List ruleNodeList = service.getRuleChainNodes(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); } @@ -129,16 +129,17 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor 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); - reprocess(ruleNodeList); } @Override - public void stop(ActorContext context) throws Exception { + public void stop(ActorContext context) { + log.trace("[{}][{}] Stopping rule chain with {} nodes", tenantId, entityId, nodeActors.size()); nodeActors.values().stream().map(RuleNodeCtx::getSelfActor).forEach(context::stop); nodeActors.clear(); nodeRoutes.clear(); @@ -147,7 +148,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor relations = service.getRuleNodeRelations(ruleNode.getId()); + log.trace("[{}][{}][{}] Processing rule node relations [{}]", tenantId, entityId, ruleNode.getId(), relations.size()); if (relations.size() == 0) { nodeRoutes.put(ruleNode.getId(), Collections.emptyList()); } else { for (EntityRelation relation : relations) { + log.trace("[{}][{}][{}] Processing rule node relation [{}]", tenantId, entityId, ruleNode.getId(), relation.getTo()); if (relation.getTo().getEntityType() == EntityType.RULE_NODE) { RuleNodeCtx ruleNodeCtx = nodeActors.get(new RuleNodeId(relation.getTo().getId())); if (ruleNodeCtx == null) { @@ -181,24 +184,23 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor pushMsgToNode(firstNode, msg, "")); + log.trace("[{}][{}] Pushing message to first rule node", entityId, firstId); + pushMsgToNode(firstNode, enrichWithRuleChainId(envelope.getTbMsg()), ""); } } void onDeviceActorToRuleEngineMsg(DeviceActorToRuleEngineMsg envelope) { checkActive(); if (firstNode != null) { - putToQueue(enrichWithRuleChainId(envelope.getTbMsg()), msg -> { - pushMsgToNode(firstNode, msg, ""); - envelope.getCallbackRef().tell(new RuleEngineQueuePutAckMsg(msg.getId()), self); - }); + pushMsgToNode(firstNode, enrichWithRuleChainId(envelope.getTbMsg()), ""); } } @@ -206,15 +208,16 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor pushMsgToNode(firstNode, msg, envelope.getFromRelationType())); + pushMsgToNode(firstNode, enrichWithRuleChainId(envelope.getMsg()), envelope.getFromRelationType()); } } else { if (firstNode != null) { pushMsgToNode(firstNode, envelope.getMsg(), envelope.getFromRelationType()); } else { - TbMsg msg = envelope.getMsg(); - EntityId ackId = msg.getRuleNodeId() != null ? msg.getRuleNodeId() : msg.getRuleChainId(); - queue.ack(tenantId, envelope.getMsg(), ackId.getId(), msg.getClusterPartition()); +// TODO: Ack this message in Kafka +// TbMsg msg = envelope.getMsg(); +// EntityId ackId = msg.getRuleNodeId() != null ? msg.getRuleNodeId() : msg.getRuleChainId(); +// queue.ack(tenantId, envelope.getMsg(), ackId.getId(), msg.getClusterPartition()); } } } @@ -234,7 +237,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor pushMsgToNode(targetNodeCtx, queuedMsg, fromRelationType)); + pushMsgToNode(targetNodeCtx, copy, fromRelationType); } private void pushToTarget(TbMsg msg, EntityId target, String fromRelationType) { diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActor.java index 273a569cdc..f5521a03f8 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActor.java @@ -32,7 +32,7 @@ public class RuleNodeActor extends ComponentActor componentClazz = Class.forName(ruleNode.getType()); TbNode tbNode = (TbNode) (componentClazz.newInstance()); diff --git a/application/src/main/java/org/thingsboard/server/actors/service/ActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/ActorService.java index f7e80e4baa..08d1dd8a11 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/ActorService.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/ActorService.java @@ -35,5 +35,4 @@ public interface ActorService extends SessionMsgProcessor, RpcMsgListener, Disco void onDeviceNameOrTypeUpdate(TenantId tenantId, DeviceId deviceId, String deviceName, String deviceType); - void onMsg(ServiceToRuleEngineMsg serviceToRuleEngineMsg); } diff --git a/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java b/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java index ed5905179f..1f084e1bb7 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/ComponentActor.java @@ -18,6 +18,7 @@ package org.thingsboard.server.actors.service; import akka.actor.ActorRef; import akka.event.Logging; import akka.event.LoggingAdapter; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.shared.ComponentMsgProcessor; import org.thingsboard.server.actors.stats.StatsPersistMsg; @@ -32,8 +33,6 @@ import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; */ public abstract class ComponentActor> extends ContextAwareActor { - protected final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); - private long lastPersistedErrorTs = 0L; protected final TenantId tenantId; protected final T id; @@ -54,13 +53,14 @@ public abstract class ComponentActor getErrorPersistFrequency()) { diff --git a/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java b/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java index 3624127194..1c7e2d2664 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/ContextAwareActor.java @@ -15,14 +15,17 @@ */ package org.thingsboard.server.actors.service; +import akka.actor.Terminated; import akka.actor.UntypedActor; -import akka.event.Logging; -import akka.event.LoggingAdapter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.msg.TbActorMsg; + public abstract class ContextAwareActor extends UntypedActor { - protected final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); + + protected final Logger log = LoggerFactory.getLogger(getClass()); public static final int ENTITY_PACK_LIMIT = 1024; @@ -35,21 +38,26 @@ public abstract class ContextAwareActor extends UntypedActor { @Override public void onReceive(Object msg) throws Exception { - if (logger.isDebugEnabled()) { - logger.debug("Processing msg: {}", msg); + if (log.isDebugEnabled()) { + log.debug("Processing msg: {}", msg); } if (msg instanceof TbActorMsg) { try { if (!process((TbActorMsg) msg)) { - logger.warning("Unknown message: {}!", msg); + log.warn("Unknown message: {}!", msg); } } catch (Exception e) { throw e; } + } else if (msg instanceof Terminated) { + processTermination((Terminated) msg); } else { - logger.warning("Unknown message: {}!", msg); + log.warn("Unknown message: {}!", msg); } } + protected void processTermination(Terminated msg) { + } + protected abstract boolean process(TbActorMsg msg); } diff --git a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java index fac5d97175..85b8943569 100644 --- a/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java +++ b/application/src/main/java/org/thingsboard/server/actors/service/DefaultActorService.java @@ -30,7 +30,6 @@ import org.thingsboard.server.actors.app.AppActor; import org.thingsboard.server.actors.rpc.RpcBroadcastMsg; import org.thingsboard.server.actors.rpc.RpcManagerActor; import org.thingsboard.server.actors.rpc.RpcSessionCreateRequestMsg; -import org.thingsboard.server.actors.session.SessionManagerActor; import org.thingsboard.server.actors.stats.StatsActor; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; @@ -38,13 +37,11 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.cluster.ToAllNodesMsg; import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; -import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.gen.cluster.ClusterAPIProtos; import org.thingsboard.server.service.cluster.discovery.DiscoveryService; import org.thingsboard.server.service.cluster.discovery.ServerInstance; @@ -68,10 +65,7 @@ public class DefaultActorService implements ActorService { public static final String APP_DISPATCHER_NAME = "app-dispatcher"; public static final String CORE_DISPATCHER_NAME = "core-dispatcher"; public static final String SYSTEM_RULE_DISPATCHER_NAME = "system-rule-dispatcher"; - public static final String SYSTEM_PLUGIN_DISPATCHER_NAME = "system-plugin-dispatcher"; public static final String TENANT_RULE_DISPATCHER_NAME = "rule-dispatcher"; - public static final String TENANT_PLUGIN_DISPATCHER_NAME = "plugin-dispatcher"; - public static final String SESSION_DISPATCHER_NAME = "session-dispatcher"; public static final String RPC_DISPATCHER_NAME = "rpc-dispatcher"; @Autowired @@ -90,8 +84,6 @@ public class DefaultActorService implements ActorService { private ActorRef appActor; - private ActorRef sessionManagerActor; - private ActorRef rpcManagerActor; @PostConstruct @@ -104,10 +96,6 @@ public class DefaultActorService implements ActorService { appActor = system.actorOf(Props.create(new AppActor.ActorCreator(actorContext)).withDispatcher(APP_DISPATCHER_NAME), "appActor"); actorContext.setAppActor(appActor); - sessionManagerActor = system.actorOf(Props.create(new SessionManagerActor.ActorCreator(actorContext)).withDispatcher(CORE_DISPATCHER_NAME), - "sessionManagerActor"); - actorContext.setSessionManagerActor(sessionManagerActor); - rpcManagerActor = system.actorOf(Props.create(new RpcManagerActor.ActorCreator(actorContext)).withDispatcher(CORE_DISPATCHER_NAME), "rpcManagerActor"); @@ -115,8 +103,6 @@ public class DefaultActorService implements ActorService { actorContext.setStatsActor(statsActor); rpcService.init(this); - - discoveryService.addListener(this); log.info("Actor system initialized."); } @@ -136,12 +122,6 @@ public class DefaultActorService implements ActorService { appActor.tell(msg, ActorRef.noSender()); } - @Override - public void process(SessionAwareMsg msg) { - log.debug("Processing session aware msg: {}", msg); - sessionManagerActor.tell(msg, ActorRef.noSender()); - } - @Override public void onServerAdded(ServerInstance server) { log.trace("Processing onServerAdded msg: {}", server); @@ -178,11 +158,6 @@ public class DefaultActorService implements ActorService { appActor.tell(new SendToClusterMsg(deviceId, msg), ActorRef.noSender()); } - @Override - public void onMsg(ServiceToRuleEngineMsg msg) { - appActor.tell(msg, ActorRef.noSender()); - } - public void broadcast(ToAllNodesMsg msg) { actorContext.getEncodingService().encode(msg); rpcService.broadcast(new RpcBroadcastMsg(ClusterAPIProtos.ClusterMessage @@ -196,16 +171,15 @@ public class DefaultActorService implements ActorService { private void broadcast(ClusterEventMsg msg) { this.appActor.tell(msg, ActorRef.noSender()); - this.sessionManagerActor.tell(msg, ActorRef.noSender()); this.rpcManagerActor.tell(msg, ActorRef.noSender()); } @Override public void onReceivedMsg(ServerAddress source, ClusterAPIProtos.ClusterMessage msg) { - ServerAddress serverAddress = new ServerAddress(source.getHost(), source.getPort()); - log.info("Received msg [{}] from [{}]", msg.getMessageType().name(), serverAddress); + ServerAddress serverAddress = new ServerAddress(source.getHost(), source.getPort(), source.getServerType()); if (log.isDebugEnabled()) { - log.info("MSG: ", msg); + log.info("Received msg [{}] from [{}]", msg.getMessageType().name(), serverAddress); + log.info("MSG: {}", msg); } switch (msg.getMessageType()) { case CLUSTER_ACTOR_MESSAGE: @@ -239,7 +213,7 @@ public class DefaultActorService implements ActorService { actorContext.getTsSubService().onRemoteTsUpdate(serverAddress, msg.getPayload().toByteArray()); break; case CLUSTER_RPC_FROM_DEVICE_RESPONSE_MESSAGE: - actorContext.getDeviceRpcService().processRemoteResponseFromDevice(serverAddress, msg.getPayload().toByteArray()); + actorContext.getDeviceRpcService().processResponseToServerSideRPCRequestFromRemoteServer(serverAddress, msg.getPayload().toByteArray()); break; case CLUSTER_DEVICE_STATE_SERVICE_MESSAGE: actorContext.getDeviceStateService().onRemoteMsg(serverAddress, msg.getPayload().toByteArray()); diff --git a/application/src/main/java/org/thingsboard/server/actors/session/ASyncMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/session/ASyncMsgProcessor.java deleted file mode 100644 index a8f14fedf5..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/session/ASyncMsgProcessor.java +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.session; - -import akka.actor.ActorContext; -import akka.event.LoggingAdapter; -import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.shared.SessionTimeoutMsg; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.core.AttributesSubscribeMsg; -import org.thingsboard.server.common.msg.core.ResponseMsg; -import org.thingsboard.server.common.msg.core.RpcSubscribeMsg; -import org.thingsboard.server.common.msg.core.SessionCloseMsg; -import org.thingsboard.server.common.msg.core.SessionOpenMsg; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; -import org.thingsboard.server.common.msg.session.BasicSessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; -import org.thingsboard.server.common.msg.session.TransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ex.SessionException; - -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - -class ASyncMsgProcessor extends AbstractSessionActorMsgProcessor { - - private boolean firstMsg = true; - private Map pendingMap = new HashMap<>(); - private Optional currentTargetServer; - private boolean subscribedToAttributeUpdates; - private boolean subscribedToRpcCommands; - - public ASyncMsgProcessor(ActorSystemContext ctx, LoggingAdapter logger, SessionId sessionId) { - super(ctx, logger, sessionId); - } - - @Override - protected void processToDeviceActorMsg(ActorContext ctx, TransportToDeviceSessionActorMsg msg) { - updateSessionCtx(msg, SessionType.ASYNC); - DeviceToDeviceActorMsg pendingMsg = toDeviceMsg(msg); - FromDeviceMsg fromDeviceMsg = pendingMsg.getPayload(); - if (firstMsg) { - if (fromDeviceMsg.getMsgType() != SessionMsgType.SESSION_OPEN) { - toDeviceMsg(new SessionOpenMsg()).ifPresent(m -> forwardToAppActor(ctx, m)); - } - firstMsg = false; - } - switch (fromDeviceMsg.getMsgType()) { - case POST_TELEMETRY_REQUEST: - case POST_ATTRIBUTES_REQUEST: - FromDeviceRequestMsg requestMsg = (FromDeviceRequestMsg) fromDeviceMsg; - if (requestMsg.getRequestId() >= 0) { - logger.debug("[{}] Pending request {} registered", requestMsg.getRequestId(), requestMsg.getMsgType()); - //TODO: handle duplicates. - pendingMap.put(requestMsg.getRequestId(), pendingMsg); - } - break; - case SUBSCRIBE_ATTRIBUTES_REQUEST: - subscribedToAttributeUpdates = true; - break; - case UNSUBSCRIBE_ATTRIBUTES_REQUEST: - subscribedToAttributeUpdates = false; - break; - case SUBSCRIBE_RPC_COMMANDS_REQUEST: - subscribedToRpcCommands = true; - break; - case UNSUBSCRIBE_RPC_COMMANDS_REQUEST: - subscribedToRpcCommands = false; - break; - default: - break; - } - currentTargetServer = forwardToAppActor(ctx, pendingMsg); - } - - @Override - public void processToDeviceMsg(ActorContext context, ToDeviceMsg msg) { - try { - if (msg.getSessionMsgType() != SessionMsgType.SESSION_CLOSE) { - switch (msg.getSessionMsgType()) { - case STATUS_CODE_RESPONSE: - case GET_ATTRIBUTES_RESPONSE: - ResponseMsg responseMsg = (ResponseMsg) msg; - if (responseMsg.getRequestId() >= 0) { - logger.debug("[{}] Pending request processed: {}", responseMsg.getRequestId(), responseMsg); - pendingMap.remove(responseMsg.getRequestId()); - } - break; - default: - break; - } - sessionCtx.onMsg(new BasicSessionActorToAdaptorMsg(this.sessionCtx, msg)); - } else { - sessionCtx.onMsg(org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg.onCredentialsRevoked(sessionCtx.getSessionId())); - } - } catch (SessionException e) { - logger.warning("Failed to push session response msg", e); - } - } - - @Override - public void processTimeoutMsg(ActorContext context, SessionTimeoutMsg msg) { - // TODO Auto-generated method stub - } - - @Override - protected void cleanupSession(ActorContext ctx) { - toDeviceMsg(new SessionCloseMsg()).ifPresent(m -> forwardToAppActor(ctx, m)); - } - - @Override - public void processClusterEvent(ActorContext context, ClusterEventMsg msg) { - if (pendingMap.size() > 0 || subscribedToAttributeUpdates || subscribedToRpcCommands) { - Optional newTargetServer = systemContext.getRoutingService().resolveById(getDeviceId()); - if (!newTargetServer.equals(currentTargetServer)) { - firstMsg = true; - currentTargetServer = newTargetServer; - pendingMap.values().forEach(v -> { - forwardToAppActor(context, v, currentTargetServer); - if (currentTargetServer.isPresent()) { - logger.debug("[{}] Forwarded msg to new server: {}", sessionId, currentTargetServer.get()); - } else { - logger.debug("[{}] Forwarded msg to local server.", sessionId); - } - }); - if (subscribedToAttributeUpdates) { - toDeviceMsg(new AttributesSubscribeMsg()).ifPresent(m -> forwardToAppActor(context, m, currentTargetServer)); - logger.debug("[{}] Forwarded attributes subscription.", sessionId); - } - if (subscribedToRpcCommands) { - toDeviceMsg(new RpcSubscribeMsg()).ifPresent(m -> forwardToAppActor(context, m, currentTargetServer)); - logger.debug("[{}] Forwarded rpc commands subscription.", sessionId); - } - } - } - } -} diff --git a/application/src/main/java/org/thingsboard/server/actors/session/AbstractSessionActorMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/session/AbstractSessionActorMsgProcessor.java deleted file mode 100644 index 469cda9c5f..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/session/AbstractSessionActorMsgProcessor.java +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.session; - -import akka.actor.ActorContext; -import akka.actor.ActorRef; -import akka.event.LoggingAdapter; -import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.shared.AbstractContextAwareMsgProcessor; -import org.thingsboard.server.actors.shared.SessionTimeoutMsg; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.device.BasicDeviceToDeviceActorMsg; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; -import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; -import org.thingsboard.server.common.msg.session.TransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; - -import java.util.Optional; - -abstract class AbstractSessionActorMsgProcessor extends AbstractContextAwareMsgProcessor { - - protected final SessionId sessionId; - protected SessionContext sessionCtx; - protected DeviceToDeviceActorMsg deviceToDeviceActorMsgPrototype; - - protected AbstractSessionActorMsgProcessor(ActorSystemContext ctx, LoggingAdapter logger, SessionId sessionId) { - super(ctx, logger); - this.sessionId = sessionId; - } - - protected abstract void processToDeviceActorMsg(ActorContext ctx, TransportToDeviceSessionActorMsg msg); - - protected abstract void processTimeoutMsg(ActorContext context, SessionTimeoutMsg msg); - - protected abstract void processToDeviceMsg(ActorContext context, ToDeviceMsg msg); - - public abstract void processClusterEvent(ActorContext context, ClusterEventMsg msg); - - protected void processSessionCtrlMsg(ActorContext ctx, SessionCtrlMsg msg) { - if (msg instanceof SessionCloseMsg) { - cleanupSession(ctx); - terminateSession(ctx, sessionId); - } - } - - protected void cleanupSession(ActorContext ctx) { - } - - protected void updateSessionCtx(TransportToDeviceSessionActorMsg msg, SessionType type) { - sessionCtx = msg.getSessionMsg().getSessionContext(); - deviceToDeviceActorMsgPrototype = new BasicDeviceToDeviceActorMsg(msg, type); - } - - protected DeviceToDeviceActorMsg toDeviceMsg(TransportToDeviceSessionActorMsg msg) { - AdaptorToSessionActorMsg adaptorMsg = msg.getSessionMsg(); - return new BasicDeviceToDeviceActorMsg(deviceToDeviceActorMsgPrototype, adaptorMsg.getMsg()); - } - - protected Optional toDeviceMsg(FromDeviceMsg msg) { - if (deviceToDeviceActorMsgPrototype != null) { - return Optional.of(new BasicDeviceToDeviceActorMsg(deviceToDeviceActorMsgPrototype, msg)); - } else { - return Optional.empty(); - } - } - - protected Optional forwardToAppActor(ActorContext ctx, DeviceToDeviceActorMsg toForward) { - Optional address = systemContext.getRoutingService().resolveById(toForward.getDeviceId()); - forwardToAppActor(ctx, toForward, address); - return address; - } - - protected Optional forwardToAppActorIfAddressChanged(ActorContext ctx, DeviceToDeviceActorMsg toForward, Optional oldAddress) { - - Optional newAddress = systemContext.getRoutingService().resolveById(toForward.getDeviceId()); - if (!newAddress.equals(oldAddress)) { - getAppActor().tell(new SendToClusterMsg(toForward.getDeviceId(), toForward - .toOtherAddress(systemContext.getRoutingService().getCurrentServer())), ctx.self()); - } - return newAddress; - } - - protected void forwardToAppActor(ActorContext ctx, DeviceToDeviceActorMsg toForward, Optional address) { - if (address.isPresent()) { - systemContext.getRpcService().tell(systemContext.getEncodingService().convertToProtoDataMessage(address.get(), - toForward.toOtherAddress(systemContext.getRoutingService().getCurrentServer()))); - } else { - getAppActor().tell(toForward, ctx.self()); - } - } - - public static void terminateSession(ActorContext ctx, SessionId sessionId) { - ctx.parent().tell(new SessionTerminationMsg(sessionId), ActorRef.noSender()); - ctx.stop(ctx.self()); - } - - public DeviceId getDeviceId() { - return deviceToDeviceActorMsgPrototype.getDeviceId(); - } -} diff --git a/application/src/main/java/org/thingsboard/server/actors/session/SessionActor.java b/application/src/main/java/org/thingsboard/server/actors/session/SessionActor.java deleted file mode 100644 index 05926c1825..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/session/SessionActor.java +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.session; - -import akka.actor.OneForOneStrategy; -import akka.actor.SupervisorStrategy; -import akka.event.Logging; -import akka.event.LoggingAdapter; -import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.service.ContextAwareActor; -import org.thingsboard.server.actors.service.ContextBasedCreator; -import org.thingsboard.server.actors.shared.SessionTimeoutMsg; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.core.ActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; -import org.thingsboard.server.common.msg.session.SessionMsg; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.TransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import scala.concurrent.duration.Duration; - -public class SessionActor extends ContextAwareActor { - - private final LoggingAdapter logger = Logging.getLogger(getContext().system(), this); - - private final SessionId sessionId; - private AbstractSessionActorMsgProcessor processor; - - private SessionActor(ActorSystemContext systemContext, SessionId sessionId) { - super(systemContext); - this.sessionId = sessionId; - } - - @Override - public SupervisorStrategy supervisorStrategy() { - return new OneForOneStrategy(-1, Duration.Inf(), - throwable -> { - logger.error(throwable, "Unknown session error"); - if (throwable instanceof Error) { - return OneForOneStrategy.escalate(); - } else { - return OneForOneStrategy.resume(); - } - }); - } - - @Override - protected boolean process(TbActorMsg msg) { - switch (msg.getMsgType()) { - case TRANSPORT_TO_DEVICE_SESSION_ACTOR_MSG: - processTransportToSessionMsg((TransportToDeviceSessionActorMsg) msg); - break; - case ACTOR_SYSTEM_TO_DEVICE_SESSION_ACTOR_MSG: - processActorsToSessionMsg((ActorSystemToDeviceSessionActorMsg) msg); - break; - case SESSION_TIMEOUT_MSG: - processTimeoutMsg((SessionTimeoutMsg) msg); - break; - case SESSION_CTRL_MSG: - processSessionCloseMsg((SessionCtrlMsg) msg); - break; - case CLUSTER_EVENT_MSG: - processClusterEvent((ClusterEventMsg) msg); - break; - default: return false; - } - return true; - } - - private void processClusterEvent(ClusterEventMsg msg) { - processor.processClusterEvent(context(), msg); - } - - private void processTransportToSessionMsg(TransportToDeviceSessionActorMsg msg) { - initProcessor(msg); - processor.processToDeviceActorMsg(context(), msg); - } - - private void processActorsToSessionMsg(ActorSystemToDeviceSessionActorMsg msg) { - processor.processToDeviceMsg(context(), msg.getMsg()); - } - - private void processTimeoutMsg(SessionTimeoutMsg msg) { - if (processor != null) { - processor.processTimeoutMsg(context(), msg); - } else { - logger.warning("[{}] Can't process timeout msg: {} without processor", sessionId, msg); - } - } - - private void processSessionCloseMsg(SessionCtrlMsg msg) { - if (processor != null) { - processor.processSessionCtrlMsg(context(), msg); - } else if (msg instanceof SessionCloseMsg) { - AbstractSessionActorMsgProcessor.terminateSession(context(), sessionId); - } else { - logger.warning("[{}] Can't process session ctrl msg: {} without processor", sessionId, msg); - } - } - - private void initProcessor(TransportToDeviceSessionActorMsg msg) { - if (processor == null) { - SessionMsg sessionMsg = (SessionMsg) msg.getSessionMsg(); - if (sessionMsg.getSessionContext().getSessionType() == SessionType.SYNC) { - processor = new SyncMsgProcessor(systemContext, logger, sessionId); - } else { - processor = new ASyncMsgProcessor(systemContext, logger, sessionId); - } - } - } - - public static class ActorCreator extends ContextBasedCreator { - private static final long serialVersionUID = 1L; - - private final SessionId sessionId; - - public ActorCreator(ActorSystemContext context, SessionId sessionId) { - super(context); - this.sessionId = sessionId; - } - - @Override - public SessionActor create() throws Exception { - return new SessionActor(context, sessionId); - } - } - -} diff --git a/application/src/main/java/org/thingsboard/server/actors/session/SessionManagerActor.java b/application/src/main/java/org/thingsboard/server/actors/session/SessionManagerActor.java deleted file mode 100644 index 9039d302e3..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/session/SessionManagerActor.java +++ /dev/null @@ -1,180 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.session; - -import akka.actor.ActorInitializationException; -import akka.actor.ActorRef; -import akka.actor.InvalidActorNameException; -import akka.actor.LocalActorRef; -import akka.actor.OneForOneStrategy; -import akka.actor.Props; -import akka.actor.SupervisorStrategy; -import akka.actor.Terminated; -import akka.event.Logging; -import akka.event.LoggingAdapter; -import akka.japi.Function; -import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.service.ContextAwareActor; -import org.thingsboard.server.actors.service.ContextBasedCreator; -import org.thingsboard.server.actors.service.DefaultActorService; -import org.thingsboard.server.actors.shared.SessionTimeoutMsg; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.core.ActorSystemToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.core.SessionCloseMsg; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; -import scala.concurrent.duration.Duration; - -import java.util.HashMap; -import java.util.Map; - -public class SessionManagerActor extends ContextAwareActor { - - private static final int INITIAL_SESSION_MAP_SIZE = 1024; - - private final LoggingAdapter log = Logging.getLogger(getContext().system(), this); - - private final Map sessionActors; - - SessionManagerActor(ActorSystemContext systemContext) { - super(systemContext); - this.sessionActors = new HashMap<>(INITIAL_SESSION_MAP_SIZE); - } - - @Override - public SupervisorStrategy supervisorStrategy() { - return strategy; - } - - @Override - protected boolean process(TbActorMsg msg) { - //TODO Move everything here, to work with TbActorMsg - return false; - } - - @Override - public void onReceive(Object msg) throws Exception { - if (msg instanceof SessionCtrlMsg) { - onSessionCtrlMsg((SessionCtrlMsg) msg); - } else if (msg instanceof SessionAwareMsg) { - forwardToSessionActor((SessionAwareMsg) msg); - } else if (msg instanceof SessionTerminationMsg) { - onSessionTermination((SessionTerminationMsg) msg); - } else if (msg instanceof Terminated) { - onTermination((Terminated) msg); - } else if (msg instanceof SessionTimeoutMsg) { - onSessionTimeout((SessionTimeoutMsg) msg); - } else if (msg instanceof ClusterEventMsg) { - broadcast(msg); - } - } - - private void broadcast(Object msg) { - sessionActors.values().forEach(actorRef -> actorRef.tell(msg, ActorRef.noSender())); - } - - private void onSessionTimeout(SessionTimeoutMsg msg) { - String sessionIdStr = msg.getSessionId().toUidStr(); - ActorRef sessionActor = sessionActors.get(sessionIdStr); - if (sessionActor != null) { - sessionActor.tell(msg, ActorRef.noSender()); - } - } - - private void onSessionCtrlMsg(SessionCtrlMsg msg) { - String sessionIdStr = msg.getSessionId().toUidStr(); - ActorRef sessionActor = sessionActors.get(sessionIdStr); - if (sessionActor != null) { - sessionActor.tell(msg, ActorRef.noSender()); - } - } - - private void onSessionTermination(SessionTerminationMsg msg) { - String sessionIdStr = msg.getId().toUidStr(); - ActorRef sessionActor = sessionActors.remove(sessionIdStr); - if (sessionActor != null) { - log.debug("[{}] Removed session actor.", sessionIdStr); - //TODO: onSubscriptionUpdate device actor about session close; - } else { - log.debug("[{}] Session actor was already removed.", sessionIdStr); - } - } - - private void forwardToSessionActor(SessionAwareMsg msg) { - if (msg instanceof ActorSystemToDeviceSessionActorMsg || msg instanceof SessionCloseMsg) { - String sessionIdStr = msg.getSessionId().toUidStr(); - ActorRef sessionActor = sessionActors.get(sessionIdStr); - if (sessionActor != null) { - sessionActor.tell(msg, ActorRef.noSender()); - } else { - log.debug("[{}] Session actor was already removed.", sessionIdStr); - } - } else { - try { - getOrCreateSessionActor(msg.getSessionId()).tell(msg, self()); - } catch (InvalidActorNameException e) { - log.info("Invalid msg : {}", msg); - } - } - } - - private ActorRef getOrCreateSessionActor(SessionId sessionId) { - String sessionIdStr = sessionId.toUidStr(); - ActorRef sessionActor = sessionActors.get(sessionIdStr); - if (sessionActor == null) { - log.debug("[{}] Creating session actor.", sessionIdStr); - sessionActor = context().actorOf( - Props.create(new SessionActor.ActorCreator(systemContext, sessionId)).withDispatcher(DefaultActorService.SESSION_DISPATCHER_NAME), - sessionIdStr); - sessionActors.put(sessionIdStr, sessionActor); - log.debug("[{}] Created session actor.", sessionIdStr); - } - return sessionActor; - } - - private void onTermination(Terminated message) { - ActorRef terminated = message.actor(); - if (terminated instanceof LocalActorRef) { - log.info("Removed actor: {}.", terminated); - //TODO: cleanup session actors map - } else { - throw new IllegalStateException("Remote actors are not supported!"); - } - } - - public static class ActorCreator extends ContextBasedCreator { - private static final long serialVersionUID = 1L; - - public ActorCreator(ActorSystemContext context) { - super(context); - } - - @Override - public SessionManagerActor create() throws Exception { - return new SessionManagerActor(context); - } - } - - private final SupervisorStrategy strategy = new OneForOneStrategy(3, Duration.create("1 minute"), new Function() { - @Override - public SupervisorStrategy.Directive apply(Throwable t) { - logger.error(t, "Unknown failure"); - return SupervisorStrategy.stop(); - } - }); -} diff --git a/application/src/main/java/org/thingsboard/server/actors/session/SyncMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/session/SyncMsgProcessor.java deleted file mode 100644 index cf8df1385f..0000000000 --- a/application/src/main/java/org/thingsboard/server/actors/session/SyncMsgProcessor.java +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.actors.session; - -import akka.actor.ActorContext; -import akka.event.LoggingAdapter; -import org.thingsboard.server.actors.ActorSystemContext; -import org.thingsboard.server.actors.shared.SessionTimeoutMsg; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.device.DeviceToDeviceActorMsg; -import org.thingsboard.server.common.msg.session.BasicSessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; -import org.thingsboard.server.common.msg.session.TransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import org.thingsboard.server.common.msg.session.ex.SessionException; - -import java.util.Optional; - -class SyncMsgProcessor extends AbstractSessionActorMsgProcessor { - private DeviceToDeviceActorMsg pendingMsg; - private Optional currentTargetServer; - private boolean pendingResponse; - - public SyncMsgProcessor(ActorSystemContext ctx, LoggingAdapter logger, SessionId sessionId) { - super(ctx, logger, sessionId); - } - - @Override - protected void processToDeviceActorMsg(ActorContext ctx, TransportToDeviceSessionActorMsg msg) { - updateSessionCtx(msg, SessionType.SYNC); - pendingMsg = toDeviceMsg(msg); - pendingResponse = true; - currentTargetServer = forwardToAppActor(ctx, pendingMsg); - scheduleMsgWithDelay(ctx, new SessionTimeoutMsg(sessionId), getTimeout(systemContext, msg.getSessionMsg().getSessionContext()), ctx.parent()); - } - - public void processTimeoutMsg(ActorContext context, SessionTimeoutMsg msg) { - if (pendingResponse) { - try { - sessionCtx.onMsg(SessionCloseMsg.onTimeout(sessionId)); - } catch (SessionException e) { - logger.warning("Failed to push session close msg", e); - } - terminateSession(context, this.sessionId); - } - } - - public void processToDeviceMsg(ActorContext context, ToDeviceMsg msg) { - try { - sessionCtx.onMsg(new BasicSessionActorToAdaptorMsg(this.sessionCtx, msg)); - pendingResponse = false; - } catch (SessionException e) { - logger.warning("Failed to push session response msg", e); - } - terminateSession(context, this.sessionId); - } - - @Override - public void processClusterEvent(ActorContext context, ClusterEventMsg msg) { - if (pendingResponse) { - Optional newTargetServer = forwardToAppActorIfAddressChanged(context, pendingMsg, currentTargetServer); - if (logger.isDebugEnabled()) { - if (!newTargetServer.equals(currentTargetServer)) { - if (newTargetServer.isPresent()) { - logger.debug("[{}] Forwarded msg to new server: {}", sessionId, newTargetServer.get()); - } else { - logger.debug("[{}] Forwarded msg to local server.", sessionId); - } - } - } - currentTargetServer = newTargetServer; - } - } - - private long getTimeout(ActorSystemContext ctx, SessionContext sessionCtx) { - return sessionCtx.getTimeout() > 0 ? sessionCtx.getTimeout() : ctx.getSyncSessionTimeout(); - } -} diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java index 8864486150..b707baf14e 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/AbstractContextAwareMsgProcessor.java @@ -22,61 +22,49 @@ import akka.event.LoggingAdapter; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; import lombok.Data; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import scala.concurrent.ExecutionContextExecutor; import scala.concurrent.duration.Duration; import java.util.concurrent.TimeUnit; +@Slf4j public abstract class AbstractContextAwareMsgProcessor { protected final ActorSystemContext systemContext; - protected final LoggingAdapter logger; protected final ObjectMapper mapper = new ObjectMapper(); - protected AbstractContextAwareMsgProcessor(ActorSystemContext systemContext, LoggingAdapter logger) { + protected AbstractContextAwareMsgProcessor(ActorSystemContext systemContext) { super(); this.systemContext = systemContext; - this.logger = logger; } - protected ActorRef getAppActor() { - return systemContext.getAppActor(); - } - - protected Scheduler getScheduler() { + private Scheduler getScheduler() { return systemContext.getScheduler(); } - protected ExecutionContextExecutor getSystemDispatcher() { + private ExecutionContextExecutor getSystemDispatcher() { return systemContext.getActorSystem().dispatcher(); } protected void schedulePeriodicMsgWithDelay(ActorContext ctx, Object msg, long delayInMs, long periodInMs) { - schedulePeriodicMsgWithDelay(ctx, msg, delayInMs, periodInMs, ctx.self()); + schedulePeriodicMsgWithDelay(msg, delayInMs, periodInMs, ctx.self()); } - protected void schedulePeriodicMsgWithDelay(ActorContext ctx, Object msg, long delayInMs, long periodInMs, ActorRef target) { - logger.debug("Scheduling periodic msg {} every {} ms with delay {} ms", msg, periodInMs, delayInMs); + private void schedulePeriodicMsgWithDelay(Object msg, long delayInMs, long periodInMs, ActorRef target) { + log.debug("Scheduling periodic msg {} every {} ms with delay {} ms", msg, periodInMs, delayInMs); getScheduler().schedule(Duration.create(delayInMs, TimeUnit.MILLISECONDS), Duration.create(periodInMs, TimeUnit.MILLISECONDS), target, msg, getSystemDispatcher(), null); } - protected void scheduleMsgWithDelay(ActorContext ctx, Object msg, long delayInMs) { - scheduleMsgWithDelay(ctx, msg, delayInMs, ctx.self()); + scheduleMsgWithDelay(msg, delayInMs, ctx.self()); } - protected void scheduleMsgWithDelay(ActorContext ctx, Object msg, long delayInMs, ActorRef target) { - logger.debug("Scheduling msg {} with delay {} ms", msg, delayInMs); + private void scheduleMsgWithDelay(Object msg, long delayInMs, ActorRef target) { + log.debug("Scheduling msg {} with delay {} ms", msg, delayInMs); getScheduler().scheduleOnce(Duration.create(delayInMs, TimeUnit.MILLISECONDS), target, msg, getSystemDispatcher(), null); } - @Data - @AllArgsConstructor - private static class ComponentConfiguration { - private final String clazz; - private final String name; - private final String configuration; - } } diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java index 1cf83397fc..46a76e6d43 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/ComponentMsgProcessor.java @@ -19,6 +19,7 @@ import akka.actor.ActorContext; import akka.event.LoggingAdapter; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; +import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.stats.StatsPersistTick; import org.thingsboard.server.common.data.id.EntityId; @@ -26,25 +27,25 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.plugin.ComponentLifecycleState; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.cluster.ClusterEventMsg; -import org.thingsboard.server.service.queue.MsgQueueService; import javax.annotation.Nullable; import java.util.function.Consumer; +@Slf4j public abstract class ComponentMsgProcessor extends AbstractContextAwareMsgProcessor { protected final TenantId tenantId; protected final T entityId; - protected final MsgQueueService queue; protected ComponentLifecycleState state; - protected ComponentMsgProcessor(ActorSystemContext systemContext, LoggingAdapter logger, TenantId tenantId, T id) { - super(systemContext, logger); + protected ComponentMsgProcessor(ActorSystemContext systemContext, TenantId tenantId, T id) { + super(systemContext); this.tenantId = tenantId; this.entityId = id; - this.queue = systemContext.getMsgQueueService(); } + public abstract String getComponentName(); + public abstract void start(ActorContext context) throws Exception; public abstract void stop(ActorContext context) throws Exception; @@ -82,22 +83,9 @@ public abstract class ComponentMsgProcessor extends Abstract protected void checkActive() { if (state != ComponentLifecycleState.ACTIVE) { - throw new IllegalStateException("Rule chain is not active!"); + log.warn("Rule chain is not active. Current state [{}] for processor [{}] tenant [{}]", state, tenantId, entityId); + throw new IllegalStateException("Rule chain is not active! " + entityId + " - " + tenantId); } } - protected void putToQueue(final TbMsg tbMsg, final Consumer onSuccess) { - EntityId entityId = tbMsg.getRuleNodeId() != null ? tbMsg.getRuleNodeId() : tbMsg.getRuleChainId(); - Futures.addCallback(queue.put(this.tenantId, tbMsg, entityId.getId(), tbMsg.getClusterPartition()), new FutureCallback() { - @Override - public void onSuccess(@Nullable Void result) { - onSuccess.accept(tbMsg); - } - - @Override - public void onFailure(Throwable t) { - logger.debug("Failed to push message [{}] to queue due to [{}]", tbMsg, t); - } - }); - } } diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/EntityActorsManager.java b/application/src/main/java/org/thingsboard/server/actors/shared/EntityActorsManager.java index 1b9e6a8074..dd03d69a62 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/EntityActorsManager.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/EntityActorsManager.java @@ -20,6 +20,8 @@ import akka.actor.ActorRef; import akka.actor.Props; import akka.actor.UntypedActor; import akka.japi.Creator; +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.actors.service.ContextAwareActor; @@ -39,11 +41,11 @@ import java.util.Map; public abstract class EntityActorsManager> { protected final ActorSystemContext systemContext; - protected final Map actors; + protected final BiMap actors; public EntityActorsManager(ActorSystemContext systemContext) { this.systemContext = systemContext; - this.actors = new HashMap<>(); + this.actors = HashBiMap.create(); } protected abstract TenantId getTenantId(); @@ -65,7 +67,8 @@ public abstract class EntityActorsManager diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/rulechain/RuleChainManager.java b/application/src/main/java/org/thingsboard/server/actors/shared/rulechain/RuleChainManager.java index 11ed5a3363..d349fec71d 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/rulechain/RuleChainManager.java +++ b/application/src/main/java/org/thingsboard/server/actors/shared/rulechain/RuleChainManager.java @@ -50,7 +50,7 @@ public abstract class RuleChainManager extends EntityActorsManager deviceActors; + private final BiMap deviceActors; private TenantActor(ActorSystemContext systemContext, TenantId tenantId) { super(systemContext, new TenantRuleChainManager(systemContext, tenantId)); this.tenantId = tenantId; - this.deviceActors = new HashMap<>(); + this.deviceActors = HashBiMap.create(); } - @Override public SupervisorStrategy supervisorStrategy() { return strategy; @@ -63,15 +67,20 @@ public class TenantActor extends RuleChainManagerActor { @Override public void preStart() { - logger.info("[{}] Starting tenant actor.", tenantId); + log.info("[{}] Starting tenant actor.", tenantId); try { initRuleChains(); - logger.info("[{}] Tenant actor started.", tenantId); + log.info("[{}] Tenant actor started.", tenantId); } catch (Exception e) { - logger.error(e, "[{}] Unknown failure", tenantId); + log.warn("[{}] Unknown failure", tenantId, e); } } + @Override + public void postStop() { + log.info("[{}] Stopping tenant actor.", tenantId); + } + @Override protected boolean process(TbActorMsg msg) { switch (msg.getMsgType()) { @@ -87,7 +96,7 @@ public class TenantActor extends RuleChainManagerActor { case DEVICE_ACTOR_TO_RULE_ENGINE_MSG: onDeviceActorToRuleEngineMsg((DeviceActorToRuleEngineMsg) msg); break; - case DEVICE_SESSION_TO_DEVICE_ACTOR_MSG: + case TRANSPORT_TO_DEVICE_ACTOR_MSG: case DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG: case DEVICE_CREDENTIALS_UPDATE_TO_DEVICE_ACTOR_MSG: case DEVICE_NAME_OR_TYPE_UPDATE_TO_DEVICE_ACTOR_MSG: @@ -105,29 +114,26 @@ public class TenantActor extends RuleChainManagerActor { return true; } - @Override - protected void broadcast(Object msg) { - super.broadcast(msg); - deviceActors.values().forEach(actorRef -> actorRef.tell(msg, ActorRef.noSender())); - } - private void onServiceToRuleEngineMsg(ServiceToRuleEngineMsg msg) { - if (ruleChainManager.getRootChainActor()!=null) - ruleChainManager.getRootChainActor().tell(msg, self()); - else logger.info("[{}] No Root Chain", msg); + if (ruleChainManager.getRootChainActor() != null) { + ruleChainManager.getRootChainActor().tell(msg, self()); + } else { + log.info("[{}] No Root Chain: {}", tenantId, msg); + } } private void onDeviceActorToRuleEngineMsg(DeviceActorToRuleEngineMsg msg) { - if (ruleChainManager.getRootChainActor()!=null) - ruleChainManager.getRootChainActor().tell(msg, self()); - else logger.info("[{}] No Root Chain", msg); + if (ruleChainManager.getRootChainActor() != null) { + ruleChainManager.getRootChainActor().tell(msg, self()); + } else { + log.info("[{}] No Root Chain: {}", tenantId, msg); + } } private void onRuleChainMsg(RuleChainAwareMsg msg) { ruleChainManager.getOrCreateActor(context(), msg.getRuleChainId()).tell(msg, self()); } - private void onToDeviceActorMsg(DeviceAwareMsg msg) { getOrCreateDeviceActor(msg.getDeviceId()).tell(msg, ActorRef.noSender()); } @@ -142,13 +148,35 @@ public class TenantActor extends RuleChainManagerActor { } target.tell(msg, ActorRef.noSender()); } else { - logger.debug("Invalid component lifecycle msg: {}", msg); + log.debug("[{}] Invalid component lifecycle msg: {}", tenantId, msg); } } private ActorRef getOrCreateDeviceActor(DeviceId deviceId) { - return deviceActors.computeIfAbsent(deviceId, k -> context().actorOf(Props.create(new DeviceActor.ActorCreator(systemContext, tenantId, deviceId)) - .withDispatcher(DefaultActorService.CORE_DISPATCHER_NAME), deviceId.toString())); + return deviceActors.computeIfAbsent(deviceId, k -> { + log.debug("[{}][{}] Creating device actor.", tenantId, deviceId); + ActorRef deviceActor = context().actorOf(Props.create(new DeviceActorCreator(systemContext, tenantId, deviceId)) + .withDispatcher(DefaultActorService.CORE_DISPATCHER_NAME) + , deviceId.toString()); + context().watch(deviceActor); + log.debug("[{}][{}] Created device actor: {}.", tenantId, deviceId, deviceActor); + return deviceActor; + }); + } + + @Override + protected void processTermination(Terminated message) { + ActorRef terminated = message.actor(); + if (terminated instanceof LocalActorRef) { + boolean removed = deviceActors.inverse().remove(terminated) != null; + if (removed) { + log.debug("[{}] Removed actor:", terminated); + } else { + log.warn("[{}] Removed actor was not found in the device map!"); + } + } else { + throw new IllegalStateException("Remote actors are not supported!"); + } } public static class ActorCreator extends ContextBasedCreator { @@ -162,7 +190,7 @@ public class TenantActor extends RuleChainManagerActor { } @Override - public TenantActor create() throws Exception { + public TenantActor create() { return new TenantActor(context, tenantId); } } @@ -170,8 +198,8 @@ public class TenantActor extends RuleChainManagerActor { private final SupervisorStrategy strategy = new OneForOneStrategy(3, Duration.create("1 minute"), new Function() { @Override public SupervisorStrategy.Directive apply(Throwable t) { - logger.error(t, "Unknown failure"); - if(t instanceof ActorInitializationException){ + log.warn("[{}] Unknown failure", tenantId, t); + if (t instanceof ActorInitializationException) { return SupervisorStrategy.stop(); } else { return SupervisorStrategy.resume(); diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java index 586e8c35ef..00885e34b0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java @@ -83,7 +83,7 @@ public class AlarmController extends BaseController { Alarm savedAlarm = checkNotNull(alarmService.createOrUpdateAlarm(alarm)); logEntityAction(savedAlarm.getId(), savedAlarm, getCurrentUser().getCustomerId(), - savedAlarm.getId() == null ? ActionType.ADDED : ActionType.UPDATED, null); + alarm.getId() == null ? ActionType.ADDED : ActionType.UPDATED, null); return savedAlarm; } catch (Exception e) { logEntityAction(emptyId(EntityType.ALARM), alarm, diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index de73fe0796..825929f863 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -19,9 +19,11 @@ import com.datastax.driver.core.utils.UUIDs; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -48,14 +50,17 @@ import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.audit.AuditLogService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.model.ModelConstants; @@ -69,6 +74,7 @@ import org.thingsboard.server.exception.ThingsboardErrorResponseHandler; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.state.DeviceStateService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import javax.mail.MessagingException; import javax.servlet.http.HttpServletRequest; @@ -139,6 +145,20 @@ public abstract class BaseController { @Autowired protected DeviceStateService deviceStateService; + @Autowired + protected EntityViewService entityViewService; + + @Autowired + protected TelemetrySubscriptionService tsSubService; + + @Autowired + protected AttributesService attributesService; + + @Value("${server.log_controller_error_stack_trace}") + @Getter + private boolean logControllerErrorStackTrace; + + @ExceptionHandler(ThingsboardException.class) public void handleThingsboardException(ThingsboardException ex, HttpServletResponse response) { errorResponseHandler.handle(ex, response); @@ -149,7 +169,7 @@ public abstract class BaseController { } private ThingsboardException handleException(Exception exception, boolean logException) { - if (logException) { + if (logException && logControllerErrorStackTrace) { log.error("Error [{}]", exception.getMessage(), exception); } @@ -247,7 +267,6 @@ public abstract class BaseController { Customer checkCustomerId(CustomerId customerId) throws ThingsboardException { try { - validateId(customerId, "Incorrect customerId " + customerId); SecurityUser authUser = getCurrentUser(); if (authUser.getAuthority() == Authority.SYS_ADMIN || (authUser.getAuthority() != Authority.TENANT_ADMIN && @@ -255,9 +274,13 @@ public abstract class BaseController { throw new ThingsboardException(YOU_DON_T_HAVE_PERMISSION_TO_PERFORM_THIS_OPERATION, ThingsboardErrorCode.PERMISSION_DENIED); } - Customer customer = customerService.findCustomerById(customerId); - checkCustomer(customer); - return customer; + if (customerId != null && !customerId.isNullUid()) { + Customer customer = customerService.findCustomerById(customerId); + checkCustomer(customer); + return customer; + } else { + return null; + } } catch (Exception e) { throw handleException(e, false); } @@ -313,6 +336,9 @@ public abstract class BaseController { case USER: checkUserId(new UserId(entityId.getId())); return; + case ENTITY_VIEW: + checkEntityViewId(new EntityViewId(entityId.getId())); + return; default: throw new IllegalArgumentException("Unsupported entity type: " + entityId.getEntityType()); } @@ -335,11 +361,26 @@ public abstract class BaseController { protected void checkDevice(Device device) throws ThingsboardException { checkNotNull(device); checkTenantId(device.getTenantId()); - if (device.getCustomerId() != null && !device.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { - checkCustomerId(device.getCustomerId()); + checkCustomerId(device.getCustomerId()); + } + + protected EntityView checkEntityViewId(EntityViewId entityViewId) throws ThingsboardException { + try { + validateId(entityViewId, "Incorrect entityViewId " + entityViewId); + EntityView entityView = entityViewService.findEntityViewById(entityViewId); + checkEntityView(entityView); + return entityView; + } catch (Exception e) { + throw handleException(e, false); } } + protected void checkEntityView(EntityView entityView) throws ThingsboardException { + checkNotNull(entityView); + checkTenantId(entityView.getTenantId()); + checkCustomerId(entityView.getCustomerId()); + } + Asset checkAssetId(AssetId assetId) throws ThingsboardException { try { validateId(assetId, "Incorrect assetId " + assetId); @@ -354,9 +395,7 @@ public abstract class BaseController { protected void checkAsset(Asset asset) throws ThingsboardException { checkNotNull(asset); checkTenantId(asset.getTenantId()); - if (asset.getCustomerId() != null && !asset.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { - checkCustomerId(asset.getCustomerId()); - } + checkCustomerId(asset.getCustomerId()); } Alarm checkAlarmId(AlarmId alarmId) throws ThingsboardException { @@ -465,8 +504,7 @@ public abstract class BaseController { ComponentDescriptor checkComponentDescriptorByClazz(String clazz) throws ThingsboardException { try { log.debug("[{}] Lookup component descriptor", clazz); - ComponentDescriptor componentDescriptor = checkNotNull(componentDescriptorService.getComponent(clazz)); - return componentDescriptor; + return checkNotNull(componentDescriptorService.getComponent(clazz)); } catch (Exception e) { throw handleException(e, false); } @@ -530,16 +568,16 @@ public abstract class BaseController { } protected I emptyId(EntityType entityType) { - return (I)EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); + return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } protected void logEntityAction(I entityId, E entity, CustomerId customerId, - ActionType actionType, Exception e, Object... additionalInfo) throws ThingsboardException { + ActionType actionType, Exception e, Object... additionalInfo) throws ThingsboardException { logEntityAction(getCurrentUser(), entityId, entity, customerId, actionType, e, additionalInfo); } protected void logEntityAction(User user, I entityId, E entity, CustomerId customerId, - ActionType actionType, Exception e, Object... additionalInfo) throws ThingsboardException { + ActionType actionType, Exception e, Object... additionalInfo) throws ThingsboardException { if (customerId == null || customerId.isNullUid()) { customerId = user.getCustomerId(); } @@ -555,7 +593,7 @@ public abstract class BaseController { } private void pushEntityActionToRuleEngine(I entityId, E entity, User user, CustomerId customerId, - ActionType actionType, Object... additionalInfo) { + ActionType actionType, Object... additionalInfo) { String msgType = null; switch (actionType) { case ADDED: @@ -579,6 +617,12 @@ public abstract class BaseController { case ATTRIBUTES_DELETED: msgType = DataConstants.ATTRIBUTES_DELETED; break; + case ALARM_ACK: + msgType = DataConstants.ALARM_ACK; + break; + case ALARM_CLEAR: + msgType = DataConstants.ALARM_CLEAR; + break; } if (!StringUtils.isEmpty(msgType)) { try { @@ -628,7 +672,7 @@ public abstract class BaseController { String scope = extractParameter(String.class, 0, additionalInfo); List keys = extractParameter(List.class, 1, additionalInfo); metaData.putValue("scope", scope); - ArrayNode attrsArrayNode = entityNode.putArray("attributes"); + ArrayNode attrsArrayNode = entityNode.putArray("attributes"); if (keys != null) { keys.forEach(attrsArrayNode::add); } @@ -637,7 +681,7 @@ public abstract class BaseController { TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), msgType, entityId, metaData, TbMsgDataType.JSON , json.writeValueAsString(entityNode) , null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(user.getTenantId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(entityId, new ServiceToRuleEngineMsg(user.getTenantId(), tbMsg))); } catch (Exception e) { log.warn("[{}] Failed to push entity action to rule engine: {}", entityId, actionType, e); } diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index 85227e76a9..0bb6a6683d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.controller; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; @@ -49,6 +51,10 @@ public class DashboardController extends BaseController { public static final String DASHBOARD_ID = "dashboardId"; + @Value("${dashboard.max_datapoints_limit}") + private long maxDatapointsLimit; + + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/dashboard/serverTime", method = RequestMethod.GET) @ResponseBody @@ -56,6 +62,13 @@ public class DashboardController extends BaseController { return System.currentTimeMillis(); } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/dashboard/maxDatapointsLimit", method = RequestMethod.GET) + @ResponseBody + public long getMaxDatapointsLimit() throws ThingsboardException { + return maxDatapointsLimit; + } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/dashboard/info/{dashboardId}", method = RequestMethod.GET) @ResponseBody diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java new file mode 100644 index 0000000000..2a9c073d34 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -0,0 +1,336 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UUIDBased; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.page.TextPageData; +import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.dao.exception.IncorrectParameterException; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.service.security.model.SecurityUser; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.CustomerController.CUSTOMER_ID; + +/** + * Created by Victor Basanets on 8/28/2017. + */ +@RestController +@RequestMapping("/api") +@Slf4j +public class EntityViewController extends BaseController { + + public static final String ENTITY_VIEW_ID = "entityViewId"; + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.GET) + @ResponseBody + public EntityView getEntityViewById(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + try { + return checkEntityViewId(new EntityViewId(toUUID(strEntityViewId))); + } catch (Exception e) { + throw handleException(e); + } + } + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityView", method = RequestMethod.POST) + @ResponseBody + public EntityView saveEntityView(@RequestBody EntityView entityView) throws ThingsboardException { + try { + entityView.setTenantId(getCurrentUser().getTenantId()); + EntityView savedEntityView = checkNotNull(entityViewService.saveEntityView(entityView)); + List>> futures = new ArrayList<>(); + if (savedEntityView.getKeys() != null && savedEntityView.getKeys().getAttributes() != null) { + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.CLIENT_SCOPE, savedEntityView.getKeys().getAttributes().getCs(), getCurrentUser())); + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SERVER_SCOPE, savedEntityView.getKeys().getAttributes().getSs(), getCurrentUser())); + futures.add(copyAttributesFromEntityToEntityView(savedEntityView, DataConstants.SHARED_SCOPE, savedEntityView.getKeys().getAttributes().getSh(), getCurrentUser())); + } + for (ListenableFuture> future : futures) { + try { + future.get(); + } catch (InterruptedException | ExecutionException e) { + throw new RuntimeException("Failed to copy attributes to entity view", e); + } + } + + logEntityAction(savedEntityView.getId(), savedEntityView, null, + entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED, null); + return savedEntityView; + } catch (Exception e) { + logEntityAction(emptyId(EntityType.ENTITY_VIEW), entityView, null, + entityView.getId() == null ? ActionType.ADDED : ActionType.UPDATED, e); + throw handleException(e); + } + } + + private ListenableFuture> copyAttributesFromEntityToEntityView(EntityView entityView, String scope, Collection keys, SecurityUser user) throws ThingsboardException { + EntityViewId entityId = entityView.getId(); + if (keys != null && !keys.isEmpty()) { + ListenableFuture> getAttrFuture = attributesService.find(entityView.getEntityId(), scope, keys); + return Futures.transform(getAttrFuture, attributeKvEntries -> { + List attributes; + if (attributeKvEntries != null && !attributeKvEntries.isEmpty()) { + attributes = + attributeKvEntries.stream() + .filter(attributeKvEntry -> { + long startTime = entityView.getStartTimeMs(); + long endTime = entityView.getEndTimeMs(); + long lastUpdateTs = attributeKvEntry.getLastUpdateTs(); + return startTime == 0 && endTime == 0 || + (endTime == 0 && startTime < lastUpdateTs) || + (startTime == 0 && endTime > lastUpdateTs) + ? true : startTime < lastUpdateTs && endTime > lastUpdateTs; + }).collect(Collectors.toList()); + tsSubService.saveAndNotify(entityId, scope, attributes, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void tmp) { + try { + logAttributesUpdated(user, entityId, scope, attributes, null); + } catch (ThingsboardException e) { + log.error("Failed to log attribute updates", e); + } + } + + @Override + public void onFailure(Throwable t) { + try { + logAttributesUpdated(user, entityId, scope, attributes, t); + } catch (ThingsboardException e) { + log.error("Failed to log attribute updates", e); + } + } + }); + } + return null; + }); + } else { + return Futures.immediateFuture(null); + } + } + + private void logAttributesUpdated(SecurityUser user, EntityId entityId, String scope, List attributes, Throwable e) throws ThingsboardException { + logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, ActionType.ATTRIBUTES_UPDATED, toException(e), + scope, attributes); + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/entityView/{entityViewId}", method = RequestMethod.DELETE) + @ResponseStatus(value = HttpStatus.OK) + public void deleteEntityView(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + try { + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + EntityView entityView = checkEntityViewId(entityViewId); + entityViewService.deleteEntityView(entityViewId); + logEntityAction(entityViewId, entityView, entityView.getCustomerId(), + ActionType.DELETED, null, strEntityViewId); + } catch (Exception e) { + logEntityAction(emptyId(EntityType.ENTITY_VIEW), + null, + null, + ActionType.DELETED, e, strEntityViewId); + throw handleException(e); + } + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/entityViews", params = {"entityViewName"}, method = RequestMethod.GET) + @ResponseBody + public EntityView getTenantEntityView( + @RequestParam String entityViewName) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(entityViewService.findEntityViewByTenantIdAndName(tenantId, entityViewName)); + } catch (Exception e) { + throw handleException(e); + } + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/{customerId}/entityView/{entityViewId}", method = RequestMethod.POST) + @ResponseBody + public EntityView assignEntityViewToCustomer(@PathVariable(CUSTOMER_ID) String strCustomerId, + @PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(CUSTOMER_ID, strCustomerId); + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + try { + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + Customer customer = checkCustomerId(customerId); + + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + checkEntityViewId(entityViewId); + + EntityView savedEntityView = checkNotNull(entityViewService.assignEntityViewToCustomer(entityViewId, customerId)); + logEntityAction(entityViewId, savedEntityView, + savedEntityView.getCustomerId(), + ActionType.ASSIGNED_TO_CUSTOMER, null, strEntityViewId, strCustomerId, customer.getName()); + return savedEntityView; + } catch (Exception e) { + logEntityAction(emptyId(EntityType.ENTITY_VIEW), null, + null, + ActionType.ASSIGNED_TO_CUSTOMER, e, strEntityViewId, strCustomerId); + throw handleException(e); + } + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/customer/entityView/{entityViewId}", method = RequestMethod.DELETE) + @ResponseBody + public EntityView unassignEntityViewFromCustomer(@PathVariable(ENTITY_VIEW_ID) String strEntityViewId) throws ThingsboardException { + checkParameter(ENTITY_VIEW_ID, strEntityViewId); + try { + EntityViewId entityViewId = new EntityViewId(toUUID(strEntityViewId)); + EntityView entityView = checkEntityViewId(entityViewId); + if (entityView.getCustomerId() == null || entityView.getCustomerId().getId().equals(ModelConstants.NULL_UUID)) { + throw new IncorrectParameterException("Entity View isn't assigned to any customer!"); + } + Customer customer = checkCustomerId(entityView.getCustomerId()); + EntityView savedEntityView = checkNotNull(entityViewService.unassignEntityViewFromCustomer(entityViewId)); + logEntityAction(entityViewId, entityView, + entityView.getCustomerId(), + ActionType.UNASSIGNED_FROM_CUSTOMER, null, strEntityViewId, customer.getId().toString(), customer.getName()); + + return savedEntityView; + } catch (Exception e) { + logEntityAction(emptyId(EntityType.ENTITY_VIEW), null, + null, + ActionType.UNASSIGNED_FROM_CUSTOMER, e, strEntityViewId); + throw handleException(e); + } + } + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/customer/{customerId}/entityViews", params = {"limit"}, method = RequestMethod.GET) + @ResponseBody + public TextPageData getCustomerEntityViews( + @PathVariable("customerId") String strCustomerId, + @RequestParam int limit, + @RequestParam(required = false) String type, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String idOffset, + @RequestParam(required = false) String textOffset) throws ThingsboardException { + checkParameter("customerId", strCustomerId); + try { + TenantId tenantId = getCurrentUser().getTenantId(); + CustomerId customerId = new CustomerId(toUUID(strCustomerId)); + checkCustomerId(customerId); + TextPageLink pageLink = createPageLink(limit, textSearch, idOffset, textOffset); + if (type != null && type.trim().length() > 0) { + return checkNotNull(entityViewService.findEntityViewsByTenantIdAndCustomerIdAndType(tenantId, customerId, pageLink, type)); + } else { + return checkNotNull(entityViewService.findEntityViewsByTenantIdAndCustomerId(tenantId, customerId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @RequestMapping(value = "/tenant/entityViews", params = {"limit"}, method = RequestMethod.GET) + @ResponseBody + public TextPageData getTenantEntityViews( + @RequestParam int limit, + @RequestParam(required = false) String type, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String idOffset, + @RequestParam(required = false) String textOffset) throws ThingsboardException { + try { + TenantId tenantId = getCurrentUser().getTenantId(); + TextPageLink pageLink = createPageLink(limit, textSearch, idOffset, textOffset); + + if (type != null && type.trim().length() > 0) { + return checkNotNull(entityViewService.findEntityViewByTenantIdAndType(tenantId, pageLink, type)); + } else { + return checkNotNull(entityViewService.findEntityViewByTenantId(tenantId, pageLink)); + } + } catch (Exception e) { + throw handleException(e); + } + } + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityViews", method = RequestMethod.POST) + @ResponseBody + public List findByQuery(@RequestBody EntityViewSearchQuery query) throws ThingsboardException { + checkNotNull(query); + checkNotNull(query.getParameters()); + checkNotNull(query.getEntityViewTypes()); + checkEntityId(query.getParameters().getEntityId()); + try { + List entityViews = checkNotNull(entityViewService.findEntityViewsByQuery(query).get()); + entityViews = entityViews.stream().filter(entityView -> { + try { + checkEntityView(entityView); + return true; + } catch (ThingsboardException e) { + return false; + } + }).collect(Collectors.toList()); + return entityViews; + } catch (Exception e) { + throw handleException(e); + } + } + + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/entityView/types", method = RequestMethod.GET) + @ResponseBody + public List getEntityViewTypes() throws ThingsboardException { + try { + SecurityUser user = getCurrentUser(); + TenantId tenantId = user.getTenantId(); + ListenableFuture> entityViewTypes = entityViewService.findEntityViewTypesByTenantId(tenantId); + return checkNotNull(entityViewTypes.get()); + } catch (Exception e) { + throw handleException(e); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java index 86b8fdacd7..fbd5faf75b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java @@ -50,7 +50,7 @@ import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.dao.event.EventService; -import org.thingsboard.server.service.script.JsSandboxService; +import org.thingsboard.server.service.script.JsInvokeService; import org.thingsboard.server.service.script.RuleNodeJsScriptEngine; import java.util.List; @@ -71,7 +71,7 @@ public class RuleChainController extends BaseController { private EventService eventService; @Autowired - private JsSandboxService jsSandboxService; + private JsInvokeService jsInvokeService; @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @RequestMapping(value = "/ruleChain/{ruleChainId}", method = RequestMethod.GET) @@ -276,7 +276,7 @@ public class RuleChainController extends BaseController { String errorText = ""; ScriptEngine engine = null; try { - engine = new RuleNodeJsScriptEngine(jsSandboxService, script, argNames); + engine = new RuleNodeJsScriptEngine(jsInvokeService, getCurrentUser().getId(), script, argNames); TbMsg inMsg = new TbMsg(UUIDs.timeBased(), msgType, null, new TbMsgMetaData(metadata), data, null, null, 0L); switch (scriptType) { case "update": diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index 7062ca97f9..ef09a7a62c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -49,9 +49,11 @@ import org.thingsboard.server.common.data.kv.Aggregation; import org.thingsboard.server.common.data.kv.AttributeKey; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; @@ -59,14 +61,11 @@ import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; -import org.thingsboard.server.common.msg.core.TelemetryUploadRequest; import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.service.security.AccessValidator; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.telemetry.AttributeData; -import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.service.telemetry.TsData; import org.thingsboard.server.service.telemetry.exception.InvalidParametersException; import org.thingsboard.server.service.telemetry.exception.UncheckedApiException; @@ -93,15 +92,9 @@ import java.util.stream.Collectors; @Slf4j public class TelemetryController extends BaseController { - @Autowired - private AttributesService attributesService; - @Autowired private TimeseriesService tsService; - @Autowired - private TelemetrySubscriptionService tsSubService; - @Autowired private AccessValidator accessValidator; @@ -256,6 +249,60 @@ public class TelemetryController extends BaseController { return saveTelemetry(entityId, requestBody, ttl); } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/{entityType}/{entityId}/timeseries/delete", method = RequestMethod.DELETE) + @ResponseBody + public DeferredResult deleteEntityTimeseries(@PathVariable("entityType") String entityType, @PathVariable("entityId") String entityIdStr, + @RequestParam(name = "keys") String keysStr, + @RequestParam(name = "deleteAllDataForKeys", defaultValue = "false") boolean deleteAllDataForKeys, + @RequestParam(name = "startTs", required = false) Long startTs, + @RequestParam(name = "endTs", required = false) Long endTs, + @RequestParam(name = "rewriteLatestIfDeleted", defaultValue = "false") boolean rewriteLatestIfDeleted) throws ThingsboardException { + EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); + return deleteTimeseries(entityId, keysStr, deleteAllDataForKeys, startTs, endTs, rewriteLatestIfDeleted); + } + + private DeferredResult deleteTimeseries(EntityId entityIdStr, String keysStr, boolean deleteAllDataForKeys, + Long startTs, Long endTs, boolean rewriteLatestIfDeleted) throws ThingsboardException { + List keys = toKeysList(keysStr); + if (keys.isEmpty()) { + return getImmediateDeferredResult("Empty keys: " + keysStr, HttpStatus.BAD_REQUEST); + } + SecurityUser user = getCurrentUser(); + + long deleteFromTs; + long deleteToTs; + if (deleteAllDataForKeys) { + deleteFromTs = 0L; + deleteToTs = System.currentTimeMillis(); + } else { + deleteFromTs = startTs; + deleteToTs = endTs; + } + + return accessValidator.validateEntityAndCallback(user, entityIdStr, (result, entityId) -> { + List deleteTsKvQueries = new ArrayList<>(); + for (String key : keys) { + deleteTsKvQueries.add(new BaseDeleteTsKvQuery(key, deleteFromTs, deleteToTs, rewriteLatestIfDeleted)); + } + + ListenableFuture> future = tsService.remove(entityId, deleteTsKvQueries); + Futures.addCallback(future, new FutureCallback>() { + @Override + public void onSuccess(@Nullable List tmp) { + logTimeseriesDeleted(user, entityId, keys, null); + result.setResult(new ResponseEntity<>(HttpStatus.OK)); + } + + @Override + public void onFailure(Throwable t) { + logTimeseriesDeleted(user, entityId, keys, t); + result.setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + }, executor); + }); + } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{deviceId}/{scope}", method = RequestMethod.DELETE) @ResponseBody @@ -352,7 +399,7 @@ public class TelemetryController extends BaseController { } private DeferredResult saveTelemetry(EntityId entityIdSrc, String requestBody, long ttl) throws ThingsboardException { - TelemetryUploadRequest telemetryRequest; + Map> telemetryRequest; JsonElement telemetryJson; try { telemetryJson = new JsonParser().parse(requestBody); @@ -360,12 +407,12 @@ public class TelemetryController extends BaseController { return getImmediateDeferredResult("Unable to parse timeseries payload: Invalid JSON body!", HttpStatus.BAD_REQUEST); } try { - telemetryRequest = JsonConverter.convertToTelemetry(telemetryJson); + telemetryRequest = JsonConverter.convertToTelemetry(telemetryJson, System.currentTimeMillis()); } catch (Exception e) { return getImmediateDeferredResult("Unable to parse timeseries payload. Invalid JSON body: " + e.getMessage(), HttpStatus.BAD_REQUEST); } List entries = new ArrayList<>(); - for (Map.Entry> entry : telemetryRequest.getData().entrySet()) { + for (Map.Entry> entry : telemetryRequest.entrySet()) { for (KvEntry kv : entry.getValue()) { entries.add(new BasicTsKvEntry(entry.getKey(), kv)); } @@ -513,6 +560,15 @@ public class TelemetryController extends BaseController { }; } + private void logTimeseriesDeleted(SecurityUser user, EntityId entityId, List keys, Throwable e) { + try { + logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, ActionType.TIMESERIES_DELETED, toException(e), + keys); + } catch (ThingsboardException te) { + log.warn("Failed to log timeseries delete", te); + } + } + private void logAttributesDeleted(SecurityUser user, EntityId entityId, String scope, List keys, Throwable e) { try { logEntityAction(user, (UUIDBased & EntityId) entityId, null, null, ActionType.ATTRIBUTES_DELETED, toException(e), diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantController.java b/application/src/main/java/org/thingsboard/server/controller/TenantController.java index 1a7c116560..155f0a1095 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantController.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TextPageData; import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.service.install.InstallScripts; @@ -84,6 +85,8 @@ public class TenantController extends BaseController { try { TenantId tenantId = new TenantId(toUUID(strTenantId)); tenantService.deleteTenant(tenantId); + + actorService.onEntityStateChange(tenantId, tenantId, ComponentLifecycleEvent.DELETED); } catch (Exception e) { throw handleException(e); } diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index f863d0bb2a..2486cd8d41 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -24,9 +24,10 @@ import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; import org.thingsboard.server.service.component.ComponentDiscoveryService; import org.thingsboard.server.service.install.DataUpdateService; -import org.thingsboard.server.service.install.DatabaseSchemaService; import org.thingsboard.server.service.install.DatabaseUpgradeService; +import org.thingsboard.server.service.install.EntityDatabaseSchemaService; import org.thingsboard.server.service.install.SystemDataLoaderService; +import org.thingsboard.server.service.install.TsDatabaseSchemaService; @Service @Profile("install") @@ -43,7 +44,10 @@ public class ThingsboardInstallService { private Boolean loadDemo; @Autowired - private DatabaseSchemaService databaseSchemaService; + private EntityDatabaseSchemaService entityDatabaseSchemaService; + + @Autowired + private TsDatabaseSchemaService tsDatabaseSchemaService; @Autowired private DatabaseUpgradeService databaseUpgradeService; @@ -88,6 +92,20 @@ public class ThingsboardInstallService { dataUpdateService.updateData("1.4.0"); + case "2.0.0": + log.info("Upgrading ThingsBoard from version 2.0.0 to 2.1.1 ..."); + + databaseUpgradeService.upgradeDatabase("2.0.0"); + + case "2.1.1": + log.info("Upgrading ThingsBoard from version 2.1.1 to 2.1.2 ..."); + + databaseUpgradeService.upgradeDatabase("2.1.1"); + case "2.1.3": + log.info("Upgrading ThingsBoard from version 2.1.3 to 2.2.0 ..."); + + databaseUpgradeService.upgradeDatabase("2.1.3"); + log.info("Updating system data..."); systemDataLoaderService.deleteSystemWidgetBundle("charts"); @@ -114,9 +132,13 @@ public class ThingsboardInstallService { log.info("Starting ThingsBoard Installation..."); - log.info("Installing DataBase schema..."); + log.info("Installing DataBase schema for entities..."); + + entityDatabaseSchemaService.createDatabaseSchema(); + + log.info("Installing DataBase schema for timeseries..."); - databaseSchemaService.createDatabaseSchema(); + tsDatabaseSchemaService.createDatabaseSchema(); log.info("Loading system data..."); diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/CurrentServerInstanceService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/CurrentServerInstanceService.java index 2232ef898c..3ae7b16af1 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/CurrentServerInstanceService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/CurrentServerInstanceService.java @@ -19,7 +19,8 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.util.Assert; -import org.thingsboard.server.gen.discovery.ServerInstanceProtos; +import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; import javax.annotation.PostConstruct; @@ -43,8 +44,7 @@ public class CurrentServerInstanceService implements ServerInstanceService { public void init() { Assert.hasLength(rpcHost, missingProperty("rpc.bind_host")); Assert.notNull(rpcPort, missingProperty("rpc.bind_port")); - - self = new ServerInstance(ServerInstanceProtos.ServerInfo.newBuilder().setHost(rpcHost).setPort(rpcPort).setTs(System.currentTimeMillis()).build()); + self = new ServerInstance(new ServerAddress(rpcHost, rpcPort, ServerType.CORE)); log.info("Current server instance: [{};{}]", self.getHost(), self.getPort()); } diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java index 516fca9484..f9caafa412 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DiscoveryService.java @@ -30,8 +30,4 @@ public interface DiscoveryService { List getOtherServers(); - boolean addListener(DiscoveryServiceListener listener); - - boolean removeListener(DiscoveryServiceListener listener); - } diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java index c21f1aa84c..358c8477d8 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/DummyDiscoveryService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.cluster.discovery; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.DependsOn; @@ -62,13 +63,5 @@ public class DummyDiscoveryService implements DiscoveryService { return Collections.emptyList(); } - @Override - public boolean addListener(DiscoveryServiceListener listener) { - return false; - } - @Override - public boolean removeListener(DiscoveryServiceListener listener) { - return false; - } } diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ServerInstance.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ServerInstance.java index 6eee5f3061..8f4525c20d 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ServerInstance.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ServerInstance.java @@ -19,7 +19,6 @@ import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.gen.discovery.ServerInstanceProtos; /** * @author Andrew Shvayka @@ -41,12 +40,6 @@ public final class ServerInstance implements Comparable { this.port = serverAddress.getPort(); } - public ServerInstance(ServerInstanceProtos.ServerInfo serverInfo) { - this.host = serverInfo.getHost(); - this.port = serverInfo.getPort(); - this.serverAddress = new ServerAddress(host, port); - } - @Override public int compareTo(ServerInstance o) { return this.serverAddress.compareTo(o.serverAddress); diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java index c3ffbabbea..f5321e5806 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/discovery/ZkDiscoveryService.java @@ -16,17 +16,22 @@ package org.thingsboard.server.service.cluster.discovery; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.SerializationException; import org.apache.commons.lang3.SerializationUtils; import org.apache.curator.framework.CuratorFramework; import org.apache.curator.framework.CuratorFrameworkFactory; +import org.apache.curator.framework.imps.CuratorFrameworkState; import org.apache.curator.framework.recipes.cache.ChildData; import org.apache.curator.framework.recipes.cache.PathChildrenCache; import org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent; import org.apache.curator.framework.recipes.cache.PathChildrenCacheListener; +import org.apache.curator.framework.state.ConnectionState; +import org.apache.curator.framework.state.ConnectionStateListener; import org.apache.curator.retry.RetryForever; import org.apache.curator.utils.CloseableUtils; import org.apache.zookeeper.CreateMode; +import org.apache.zookeeper.KeeperException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -35,7 +40,9 @@ import org.springframework.context.ApplicationListener; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; import org.springframework.util.Assert; +import org.thingsboard.server.actors.service.ActorService; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.service.cluster.routing.ClusterRoutingService; import org.thingsboard.server.service.state.DeviceStateService; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import org.thingsboard.server.utils.MiscUtils; @@ -44,9 +51,10 @@ import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.util.List; import java.util.NoSuchElementException; -import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; +import static org.apache.curator.framework.recipes.cache.PathChildrenCacheEvent.Type.CHILD_REMOVED; + /** * @author Andrew Shvayka */ @@ -79,12 +87,19 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi @Lazy private DeviceStateService deviceStateService; - private final List listeners = new CopyOnWriteArrayList<>(); + @Autowired + @Lazy + private ActorService actorService; + + @Autowired + @Lazy + private ClusterRoutingService routingService; private CuratorFramework client; private PathChildrenCache cache; private String nodePath; + private volatile boolean stopped = false; @PostConstruct public void init() { @@ -106,6 +121,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi cache.start(); } catch (Exception e) { log.error("Failed to connect to ZK: {}", e.getMessage(), e); + CloseableUtils.closeQuietly(cache); CloseableUtils.closeQuietly(client); throw new RuntimeException(e); } @@ -113,23 +129,74 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi @PreDestroy public void destroy() { + stopped = true; unpublishCurrentServer(); + CloseableUtils.closeQuietly(cache); CloseableUtils.closeQuietly(client); log.info("Stopped discovery service"); } @Override - public void publishCurrentServer() { + public synchronized void publishCurrentServer() { + ServerInstance self = this.serverInstance.getSelf(); + if (currentServerExists()) { + log.info("[{}:{}] ZK node for current instance already exists, NOT created new one: {}", self.getHost(), self.getPort(), nodePath); + } else { + try { + log.info("[{}:{}] Creating ZK node for current instance", self.getHost(), self.getPort()); + nodePath = client.create() + .creatingParentsIfNeeded() + .withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(zkNodesDir + "/", SerializationUtils.serialize(self.getServerAddress())); + log.info("[{}:{}] Created ZK node for current instance: {}", self.getHost(), self.getPort(), nodePath); + client.getConnectionStateListenable().addListener(checkReconnect(self)); + } catch (Exception e) { + log.error("Failed to create ZK node", e); + throw new RuntimeException(e); + } + } + } + + private boolean currentServerExists() { + if (nodePath == null) { + return false; + } try { ServerInstance self = this.serverInstance.getSelf(); - log.info("[{}:{}] Creating ZK node for current instance", self.getHost(), self.getPort()); - nodePath = client.create() - .creatingParentsIfNeeded() - .withMode(CreateMode.EPHEMERAL_SEQUENTIAL).forPath(zkNodesDir + "/", SerializationUtils.serialize(self.getServerAddress())); - log.info("[{}:{}] Created ZK node for current instance: {}", self.getHost(), self.getPort(), nodePath); + ServerAddress registeredServerAdress = null; + registeredServerAdress = SerializationUtils.deserialize(client.getData().forPath(nodePath)); + if (self.getServerAddress() != null && self.getServerAddress().equals(registeredServerAdress)) { + return true; + } + } catch (KeeperException.NoNodeException e) { + log.info("ZK node does not exist: {}", nodePath); } catch (Exception e) { - log.error("Failed to create ZK node", e); - throw new RuntimeException(e); + log.error("Couldn't check if ZK node exists", e); + } + return false; + } + + private ConnectionStateListener checkReconnect(ServerInstance self) { + return (client, newState) -> { + log.info("[{}:{}] ZK state changed: {}", self.getHost(), self.getPort(), newState); + if (newState == ConnectionState.LOST) { + reconnect(); + } + }; + } + + private boolean reconnectInProgress = false; + + private synchronized void reconnect() { + if (!reconnectInProgress) { + reconnectInProgress = true; + try { + client.blockUntilConnected(); + publishCurrentServer(); + } catch (InterruptedException e) { + log.error("Failed to reconnect to ZK: {}", e.getMessage(), e); + } finally { + reconnectInProgress = false; + } } } @@ -156,7 +223,7 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi .filter(cd -> !cd.getPath().equals(nodePath)) .map(cd -> { try { - return new ServerInstance( (ServerAddress) SerializationUtils.deserialize(cd.getData())); + return new ServerInstance((ServerAddress) SerializationUtils.deserialize(cd.getData())); } catch (NoSuchElementException e) { log.error("Failed to decode ZK node", e); throw new RuntimeException(e); @@ -165,18 +232,16 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi .collect(Collectors.toList()); } - @Override - public boolean addListener(DiscoveryServiceListener listener) { - return listeners.add(listener); - } - - @Override - public boolean removeListener(DiscoveryServiceListener listener) { - return listeners.remove(listener); - } - @Override public void onApplicationEvent(ApplicationReadyEvent applicationReadyEvent) { + if (stopped) { + log.debug("Ignoring application ready event. Service is stopped."); + return; + } + if (client.getState() != CuratorFrameworkState.STARTED) { + log.debug("Ignoring application ready event, ZK client is not started, ZK client state [{}]", client.getState()); + return; + } publishCurrentServer(); getOtherServers().forEach( server -> log.info("Found active server: [{}:{}]", server.getHost(), server.getPort()) @@ -185,6 +250,14 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi @Override public void childEvent(CuratorFramework curatorFramework, PathChildrenCacheEvent pathChildrenCacheEvent) throws Exception { + if (stopped) { + log.debug("Ignoring {}. Service is stopped.", pathChildrenCacheEvent); + return; + } + if (client.getState() != CuratorFrameworkState.STARTED) { + log.debug("Ignoring {}, ZK client is not started, ZK client state [{}]", pathChildrenCacheEvent, client.getState()); + return; + } ChildData data = pathChildrenCacheEvent.getData(); if (data == null) { log.debug("Ignoring {} due to empty child data", pathChildrenCacheEvent); @@ -193,12 +266,16 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi log.debug("Ignoring {} due to empty child's data", pathChildrenCacheEvent); return; } else if (nodePath != null && nodePath.equals(data.getPath())) { + if (pathChildrenCacheEvent.getType() == CHILD_REMOVED) { + log.info("ZK node for current instance is somehow deleted."); + publishCurrentServer(); + } log.debug("Ignoring event about current server {}", pathChildrenCacheEvent); return; } ServerInstance instance; try { - ServerAddress serverAddress = SerializationUtils.deserialize(data.getData()); + ServerAddress serverAddress = SerializationUtils.deserialize(data.getData()); instance = new ServerInstance(serverAddress); } catch (SerializationException e) { log.error("Failed to decode server instance for node {}", data.getPath(), e); @@ -207,17 +284,20 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi log.info("Processing [{}] event for [{}:{}]", pathChildrenCacheEvent.getType(), instance.getHost(), instance.getPort()); switch (pathChildrenCacheEvent.getType()) { case CHILD_ADDED: + routingService.onServerAdded(instance); tsSubService.onClusterUpdate(); deviceStateService.onClusterUpdate(); - listeners.forEach(listener -> listener.onServerAdded(instance)); + actorService.onServerAdded(instance); break; case CHILD_UPDATED: - listeners.forEach(listener -> listener.onServerUpdated(instance)); + routingService.onServerUpdated(instance); + actorService.onServerUpdated(instance); break; case CHILD_REMOVED: + routingService.onServerRemoved(instance); tsSubService.onClusterUpdate(); deviceStateService.onClusterUpdate(); - listeners.forEach(listener -> listener.onServerRemoved(instance)); + actorService.onServerRemoved(instance); break; default: break; diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/routing/ClusterRoutingService.java b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ClusterRoutingService.java index 272073dd23..bb76c74e28 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/routing/ClusterRoutingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ClusterRoutingService.java @@ -17,6 +17,8 @@ package org.thingsboard.server.service.cluster.routing; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; +import org.thingsboard.server.service.cluster.discovery.DiscoveryServiceListener; import java.util.Optional; import java.util.UUID; @@ -24,7 +26,7 @@ import java.util.UUID; /** * @author Andrew Shvayka */ -public interface ClusterRoutingService { +public interface ClusterRoutingService extends DiscoveryServiceListener { ServerAddress getCurrentServer(); @@ -32,4 +34,9 @@ public interface ClusterRoutingService { Optional resolveById(EntityId entityId); + Optional resolveByUuid(ServerType server, UUID uuid); + + Optional resolveById(ServerType server, EntityId entityId); + + } diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentClusterRoutingService.java b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentClusterRoutingService.java index 5087b5ce46..f47f6d2e8e 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentClusterRoutingService.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentClusterRoutingService.java @@ -24,12 +24,14 @@ import org.springframework.stereotype.Service; import org.springframework.util.Assert; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; import org.thingsboard.server.service.cluster.discovery.DiscoveryService; import org.thingsboard.server.service.cluster.discovery.DiscoveryServiceListener; import org.thingsboard.server.service.cluster.discovery.ServerInstance; import org.thingsboard.server.utils.MiscUtils; import javax.annotation.PostConstruct; +import java.util.Arrays; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ConcurrentNavigableMap; @@ -41,7 +43,7 @@ import java.util.concurrent.ConcurrentSkipListMap; @Service @Slf4j -public class ConsistentClusterRoutingService implements ClusterRoutingService, DiscoveryServiceListener { +public class ConsistentClusterRoutingService implements ClusterRoutingService { @Autowired private DiscoveryService discoveryService; @@ -55,15 +57,19 @@ public class ConsistentClusterRoutingService implements ClusterRoutingService, D private HashFunction hashFunction; - private final ConcurrentNavigableMap circle = - new ConcurrentSkipListMap<>(); + private ConsistentHashCircle[] circles; + private ConsistentHashCircle rootCircle; @PostConstruct public void init() { log.info("Initializing Cluster routing service!"); - hashFunction = MiscUtils.forName(hashFunctionName); - discoveryService.addListener(this); + this.hashFunction = MiscUtils.forName(hashFunctionName); this.currentServer = discoveryService.getCurrentServer(); + this.circles = new ConsistentHashCircle[ServerType.values().length]; + for (ServerType serverType : ServerType.values()) { + circles[serverType.ordinal()] = new ConsistentHashCircle(); + } + rootCircle = circles[ServerType.CORE.ordinal()]; addNode(discoveryService.getCurrentServer()); for (ServerInstance instance : discoveryService.getOtherServers()) { addNode(instance); @@ -79,11 +85,25 @@ public class ConsistentClusterRoutingService implements ClusterRoutingService, D @Override public Optional resolveById(EntityId entityId) { - return resolveByUuid(entityId.getId()); + return resolveByUuid(rootCircle, entityId.getId()); } @Override public Optional resolveByUuid(UUID uuid) { + return resolveByUuid(rootCircle, uuid); + } + + @Override + public Optional resolveByUuid(ServerType server, UUID uuid) { + return resolveByUuid(circles[server.ordinal()], uuid); + } + + @Override + public Optional resolveById(ServerType server, EntityId entityId) { + return resolveByUuid(circles[server.ordinal()], entityId.getId()); + } + + private Optional resolveByUuid(ConsistentHashCircle circle, UUID uuid) { Assert.notNull(uuid); if (circle.isEmpty()) { return Optional.empty(); @@ -125,13 +145,13 @@ public class ConsistentClusterRoutingService implements ClusterRoutingService, D private void addNode(ServerInstance instance) { for (int i = 0; i < virtualNodesSize; i++) { - circle.put(hash(instance, i).asLong(), instance); + circles[instance.getServerAddress().getServerType().ordinal()].put(hash(instance, i).asLong(), instance); } } private void removeNode(ServerInstance instance) { for (int i = 0; i < virtualNodesSize; i++) { - circle.remove(hash(instance, i).asLong()); + circles[instance.getServerAddress().getServerType().ordinal()].remove(hash(instance, i).asLong()); } } @@ -141,7 +161,7 @@ public class ConsistentClusterRoutingService implements ClusterRoutingService, D private void logCircle() { log.trace("Consistent Hash Circle Start"); - circle.entrySet().forEach((e) -> log.debug("{} -> {}", e.getKey(), e.getValue().getServerAddress())); + Arrays.asList(circles).forEach(ConsistentHashCircle::log); log.trace("Consistent Hash Circle End"); } diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentHashCircle.java b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentHashCircle.java new file mode 100644 index 0000000000..a8c8f37a02 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/cluster/routing/ConsistentHashCircle.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.cluster.routing; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.service.cluster.discovery.ServerInstance; + +import java.util.concurrent.ConcurrentNavigableMap; +import java.util.concurrent.ConcurrentSkipListMap; + +/** + * Created by ashvayka on 23.09.18. + */ +@Slf4j +public class ConsistentHashCircle { + private final ConcurrentNavigableMap circle = + new ConcurrentSkipListMap<>(); + + public void put(long hash, ServerInstance instance) { + circle.put(hash, instance); + } + + public void remove(long hash) { + circle.remove(hash); + } + + public boolean isEmpty() { + return circle.isEmpty(); + } + + public boolean containsKey(Long hash) { + return circle.containsKey(hash); + } + + public ConcurrentNavigableMap tailMap(Long hash) { + return circle.tailMap(hash); + } + + public Long firstKey() { + return circle.firstKey(); + } + + public ServerInstance get(Long hash) { + return circle.get(hash); + } + + public void log() { + circle.entrySet().forEach((e) -> log.debug("{} -> {}", e.getKey(), e.getValue().getServerAddress())); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java b/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java index 7216c4319f..dd0a995c84 100644 --- a/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java +++ b/application/src/main/java/org/thingsboard/server/service/cluster/rpc/GrpcSession.java @@ -15,10 +15,13 @@ */ package org.thingsboard.server.service.cluster.rpc; +import io.grpc.Channel; +import io.grpc.ManagedChannel; import io.grpc.stub.StreamObserver; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.msg.cluster.ServerType; import org.thingsboard.server.gen.cluster.ClusterAPIProtos; import java.io.Closeable; @@ -33,6 +36,7 @@ public final class GrpcSession implements Closeable { private final UUID sessionId; private final boolean client; private final GrpcSessionListener listener; + private final ManagedChannel channel; private StreamObserver inputStream; private StreamObserver outputStream; @@ -40,10 +44,10 @@ public final class GrpcSession implements Closeable { private ServerAddress remoteServer; public GrpcSession(GrpcSessionListener listener) { - this(null, listener); + this(null, listener, null); } - public GrpcSession(ServerAddress remoteServer, GrpcSessionListener listener) { + public GrpcSession(ServerAddress remoteServer, GrpcSessionListener listener, ManagedChannel channel) { this.sessionId = UUID.randomUUID(); this.listener = listener; if (remoteServer != null) { @@ -53,6 +57,7 @@ public final class GrpcSession implements Closeable { } else { this.client = false; } + this.channel = channel; } public void initInputStream() { @@ -61,8 +66,8 @@ public final class GrpcSession implements Closeable { public void onNext(ClusterAPIProtos.ClusterMessage clusterMessage) { if (!connected && clusterMessage.getMessageType() == ClusterAPIProtos.MessageType.CONNECT_RPC_MESSAGE) { connected = true; - ServerAddress rpcAddress = new ServerAddress(clusterMessage.getServerAddress().getHost(), clusterMessage.getServerAddress().getPort()); - remoteServer = new ServerAddress(rpcAddress.getHost(), rpcAddress.getPort()); + ServerAddress rpcAddress = new ServerAddress(clusterMessage.getServerAddress().getHost(), clusterMessage.getServerAddress().getPort(), ServerType.CORE); + remoteServer = new ServerAddress(rpcAddress.getHost(), rpcAddress.getPort(), ServerType.CORE); listener.onConnected(GrpcSession.this); } if (connected) { @@ -104,5 +109,8 @@ public final class GrpcSession implements Closeable { } catch (IllegalStateException e) { log.debug("[{}] Failed to close output stream: {}", sessionId, e.getMessage()); } + if (channel != null) { + channel.shutdownNow(); + } } } diff --git a/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java b/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java index f3ceed2069..545baf8e11 100644 --- a/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java +++ b/application/src/main/java/org/thingsboard/server/service/component/AnnotationComponentDiscoveryService.java @@ -30,7 +30,6 @@ import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.rule.engine.api.NodeDefinition; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbRelationTypes; -import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.plugin.ComponentDescriptor; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.dao.component.ComponentDescriptorService; @@ -52,6 +51,7 @@ import java.util.Set; @Slf4j public class AnnotationComponentDiscoveryService implements ComponentDiscoveryService { + public static final int MAX_OPTIMISITC_RETRIES = 3; @Value("${plugins.scan_packages}") private String[] scanPackages; @@ -81,17 +81,32 @@ public class AnnotationComponentDiscoveryService implements ComponentDiscoverySe private void registerRuleNodeComponents() { Set ruleNodeBeanDefinitions = getBeanDefinitions(RuleNode.class); for (BeanDefinition def : ruleNodeBeanDefinitions) { - try { - String clazzName = def.getBeanClassName(); - Class clazz = Class.forName(clazzName); - RuleNode ruleNodeAnnotation = clazz.getAnnotation(RuleNode.class); - ComponentType type = ruleNodeAnnotation.type(); - ComponentDescriptor component = scanAndPersistComponent(def, type); - components.put(component.getClazz(), component); - componentsMap.computeIfAbsent(type, k -> new ArrayList<>()).add(component); - } catch (Exception e) { - log.error("Can't initialize component {}, due to {}", def.getBeanClassName(), e.getMessage(), e); - throw new RuntimeException(e); + int retryCount = 0; + Exception cause = null; + while (retryCount < MAX_OPTIMISITC_RETRIES) { + try { + String clazzName = def.getBeanClassName(); + Class clazz = Class.forName(clazzName); + RuleNode ruleNodeAnnotation = clazz.getAnnotation(RuleNode.class); + ComponentType type = ruleNodeAnnotation.type(); + ComponentDescriptor component = scanAndPersistComponent(def, type); + components.put(component.getClazz(), component); + componentsMap.computeIfAbsent(type, k -> new ArrayList<>()).add(component); + break; + } catch (Exception e) { + log.trace("Can't initialize component {}, due to {}", def.getBeanClassName(), e.getMessage(), e); + cause = e; + retryCount++; + try { + Thread.sleep(1000); + } catch (InterruptedException e1) { + throw new RuntimeException(e1); + } + } + } + if (cause != null && retryCount == MAX_OPTIMISITC_RETRIES) { + log.error("Can't initialize component {}, due to {}", def.getBeanClassName(), cause.getMessage(), cause); + throw new RuntimeException(cause); } } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java similarity index 78% rename from application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java rename to application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java index dd76b21844..10559ba2d2 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/CassandraDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraAbstractDatabaseSchemaService.java @@ -17,24 +17,17 @@ package org.thingsboard.server.service.install; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Service; import org.thingsboard.server.dao.cassandra.CassandraInstallCluster; -import org.thingsboard.server.dao.util.NoSqlDao; import org.thingsboard.server.service.install.cql.CQLStatementsParser; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; -@Service -@NoSqlDao -@Profile("install") @Slf4j -public class CassandraDatabaseSchemaService implements DatabaseSchemaService { +public abstract class CassandraAbstractDatabaseSchemaService implements DatabaseSchemaService { private static final String CASSANDRA_DIR = "cassandra"; - private static final String SCHEMA_CQL = "schema.cql"; @Autowired private CassandraInstallCluster cluster; @@ -42,10 +35,16 @@ public class CassandraDatabaseSchemaService implements DatabaseSchemaService { @Autowired private InstallScripts installScripts; + private final String schemaCql; + + protected CassandraAbstractDatabaseSchemaService(String schemaCql) { + this.schemaCql = schemaCql; + } + @Override public void createDatabaseSchema() throws Exception { - log.info("Installing Cassandra DataBase schema..."); - Path schemaFile = Paths.get(installScripts.getDataDir(), CASSANDRA_DIR, SCHEMA_CQL); + log.info("Installing Cassandra DataBase schema part: " + schemaCql); + Path schemaFile = Paths.get(installScripts.getDataDir(), CASSANDRA_DIR, schemaCql); loadCql(schemaFile); } 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 f4ff92cf23..a2aeefaecf 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 @@ -39,10 +39,19 @@ import static org.thingsboard.server.service.install.DatabaseHelper.CONFIGURATIO import static org.thingsboard.server.service.install.DatabaseHelper.CUSTOMER_ID; import static org.thingsboard.server.service.install.DatabaseHelper.DASHBOARD; import static org.thingsboard.server.service.install.DatabaseHelper.DEVICE; +import static org.thingsboard.server.service.install.DatabaseHelper.END_TS; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_ID; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_TYPE; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_VIEW; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_VIEWS; import static org.thingsboard.server.service.install.DatabaseHelper.ID; +import static org.thingsboard.server.service.install.DatabaseHelper.KEYS; +import static org.thingsboard.server.service.install.DatabaseHelper.NAME; import static org.thingsboard.server.service.install.DatabaseHelper.SEARCH_TEXT; +import static org.thingsboard.server.service.install.DatabaseHelper.START_TS; import static org.thingsboard.server.service.install.DatabaseHelper.TENANT_ID; import static org.thingsboard.server.service.install.DatabaseHelper.TITLE; +import static org.thingsboard.server.service.install.DatabaseHelper.TYPE; @Service @NoSqlDao @@ -203,6 +212,47 @@ public class CassandraDatabaseUpgradeService implements DatabaseUpgradeService { log.info("Schema updated."); break; + + case "2.0.0": + + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.1.1", SCHEMA_UPDATE_CQL); + loadCql(schemaUpdateFile); + log.info("Schema updated."); + + break; + + case "2.1.1": + + log.info("Upgrading Cassandra DataBase from version {} to 2.1.2 ...", fromVersion); + + cluster.getSession(); + + ks = cluster.getCluster().getMetadata().getKeyspace(cluster.getKeyspaceName()); + + log.info("Dumping entity views ..."); + Path entityViewsDump = CassandraDbHelper.dumpCfIfExists(ks, cluster.getSession(), ENTITY_VIEWS, + new String[]{ID, ENTITY_ID, ENTITY_TYPE, TENANT_ID, CUSTOMER_ID, NAME, TYPE, KEYS, START_TS, END_TS, SEARCH_TEXT, ADDITIONAL_INFO}, + new String[]{"", "", "", "", "", "", "default", "", "0", "0", "", ""}, + "tb-entity-views"); + log.info("Entity views dumped."); + + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.1.2", SCHEMA_UPDATE_CQL); + loadCql(schemaUpdateFile); + log.info("Schema updated."); + + log.info("Restoring entity views ..."); + if (entityViewsDump != null) { + CassandraDbHelper.loadCf(ks, cluster.getSession(), ENTITY_VIEW, + new String[]{ID, ENTITY_ID, ENTITY_TYPE, TENANT_ID, CUSTOMER_ID, NAME, TYPE, KEYS, START_TS, END_TS, SEARCH_TEXT, ADDITIONAL_INFO}, entityViewsDump); + Files.deleteIfExists(entityViewsDump); + } + log.info("Entity views restored."); + + break; + case "2.1.3": + break; default: throw new RuntimeException("Unable to upgrade Cassandra database, unsupported fromVersion: " + fromVersion); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java new file mode 100644 index 0000000000..b492a51cbc --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraEntityDatabaseSchemaService.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.NoSqlDao; + +@Service +@NoSqlDao +@Profile("install") +public class CassandraEntityDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService + implements EntityDatabaseSchemaService { + public CassandraEntityDatabaseSchemaService() { + super("schema-entities.cql"); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java new file mode 100644 index 0000000000..1a3a2e429b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/CassandraTsDatabaseSchemaService.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.NoSqlTsDao; + +@Service +@NoSqlTsDao +@Profile("install") +public class CassandraTsDatabaseSchemaService extends CassandraAbstractDatabaseSchemaService + implements TsDatabaseSchemaService { + public CassandraTsDatabaseSchemaService() { + super("schema-ts.cql"); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/install/DatabaseHelper.java b/application/src/main/java/org/thingsboard/server/service/install/DatabaseHelper.java index 2f9b4a5ed3..699eaf1846 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DatabaseHelper.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DatabaseHelper.java @@ -45,14 +45,23 @@ public class DatabaseHelper { public static final CSVFormat CSV_DUMP_FORMAT = CSVFormat.DEFAULT.withNullString("\\N"); public static final String DEVICE = "device"; + public static final String ENTITY_ID = "entity_id"; public static final String TENANT_ID = "tenant_id"; + public static final String ENTITY_TYPE = "entity_type"; public static final String CUSTOMER_ID = "customer_id"; public static final String SEARCH_TEXT = "search_text"; public static final String ADDITIONAL_INFO = "additional_info"; public static final String ASSET = "asset"; public static final String DASHBOARD = "dashboard"; + public static final String ENTITY_VIEWS = "entity_views"; + public static final String ENTITY_VIEW = "entity_view"; public static final String ID = "id"; public static final String TITLE = "title"; + public static final String TYPE = "type"; + public static final String NAME = "name"; + public static final String KEYS = "keys"; + public static final String START_TS = "start_ts"; + public static final String END_TS = "end_ts"; public static final String ASSIGNED_CUSTOMERS = "assigned_customers"; public static final String CONFIGURATION = "configuration"; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionActorToAdaptorMsg.java b/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java similarity index 81% rename from common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionActorToAdaptorMsg.java rename to application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java index fa1f6d5e8f..c215ea0d8f 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionActorToAdaptorMsg.java +++ b/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java @@ -13,10 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.session; - -public interface SessionActorToAdaptorMsg extends SessionMsg { - - ToDeviceMsg getMsg(); +package org.thingsboard.server.service.install; +public interface EntityDatabaseSchemaService extends DatabaseSchemaService { } diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java similarity index 82% rename from application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java rename to application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java index 1daf66086a..4a39b8f60b 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseSchemaService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java @@ -18,9 +18,6 @@ package org.thingsboard.server.service.install; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.stereotype.Service; -import org.thingsboard.server.dao.util.SqlDao; import java.nio.charset.Charset; import java.nio.file.Files; @@ -29,14 +26,10 @@ import java.nio.file.Paths; import java.sql.Connection; import java.sql.DriverManager; -@Service -@Profile("install") @Slf4j -@SqlDao -public class SqlDatabaseSchemaService implements DatabaseSchemaService { +public abstract class SqlAbstractDatabaseSchemaService implements DatabaseSchemaService { private static final String SQL_DIR = "sql"; - private static final String SCHEMA_SQL = "schema.sql"; @Value("${spring.datasource.url}") private String dbUrl; @@ -50,12 +43,18 @@ public class SqlDatabaseSchemaService implements DatabaseSchemaService { @Autowired private InstallScripts installScripts; + private final String schemaSql; + + protected SqlAbstractDatabaseSchemaService(String schemaSql) { + this.schemaSql = schemaSql; + } + @Override public void createDatabaseSchema() throws Exception { - log.info("Installing SQL DataBase schema..."); + log.info("Installing SQL DataBase schema part: " + schemaSql); - Path schemaFile = Paths.get(installScripts.getDataDir(), SQL_DIR, SCHEMA_SQL); + Path schemaFile = Paths.get(installScripts.getDataDir(), SQL_DIR, schemaSql); try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { String sql = new String(Files.readAllBytes(schemaFile), Charset.forName("UTF-8")); conn.createStatement().execute(sql); //NOSONAR, ignoring because method used to load initial thingsboard database schema diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 3a4a837217..b4a725d941 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -31,14 +31,24 @@ import java.nio.file.Paths; import java.sql.Connection; import java.sql.DriverManager; +import static org.thingsboard.server.service.install.DatabaseHelper.ADDITIONAL_INFO; import static org.thingsboard.server.service.install.DatabaseHelper.ASSIGNED_CUSTOMERS; import static org.thingsboard.server.service.install.DatabaseHelper.CONFIGURATION; import static org.thingsboard.server.service.install.DatabaseHelper.CUSTOMER_ID; import static org.thingsboard.server.service.install.DatabaseHelper.DASHBOARD; +import static org.thingsboard.server.service.install.DatabaseHelper.END_TS; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_ID; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_TYPE; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_VIEW; +import static org.thingsboard.server.service.install.DatabaseHelper.ENTITY_VIEWS; import static org.thingsboard.server.service.install.DatabaseHelper.ID; +import static org.thingsboard.server.service.install.DatabaseHelper.KEYS; +import static org.thingsboard.server.service.install.DatabaseHelper.NAME; import static org.thingsboard.server.service.install.DatabaseHelper.SEARCH_TEXT; +import static org.thingsboard.server.service.install.DatabaseHelper.START_TS; import static org.thingsboard.server.service.install.DatabaseHelper.TENANT_ID; import static org.thingsboard.server.service.install.DatabaseHelper.TITLE; +import static org.thingsboard.server.service.install.DatabaseHelper.TYPE; @Service @Profile("install") @@ -107,6 +117,46 @@ public class SqlDatabaseUpgradeService implements DatabaseUpgradeService { log.info("Schema updated."); } break; + case "2.0.0": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.1.1", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Schema updated."); + } + break; + case "2.1.1": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + + log.info("Dumping entity views ..."); + Path entityViewsDump = SqlDbHelper.dumpTableIfExists(conn, ENTITY_VIEWS, + new String[]{ID, ENTITY_ID, ENTITY_TYPE, TENANT_ID, CUSTOMER_ID, TYPE, NAME, KEYS, START_TS, END_TS, SEARCH_TEXT, ADDITIONAL_INFO}, + new String[]{"", "", "", "", "", "default", "", "", "0", "0", "", ""}, + "tb-entity-views", true); + log.info("Entity views dumped."); + + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.1.2", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Schema updated."); + + log.info("Restoring entity views ..."); + if (entityViewsDump != null) { + SqlDbHelper.loadTable(conn, ENTITY_VIEW, + new String[]{ID, ENTITY_ID, ENTITY_TYPE, TENANT_ID, CUSTOMER_ID, TYPE, NAME, KEYS, START_TS, END_TS, SEARCH_TEXT, ADDITIONAL_INFO}, entityViewsDump, true); + Files.deleteIfExists(entityViewsDump); + } + log.info("Entity views restored."); + } + break; + case "2.1.3": + try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) { + log.info("Updating schema ..."); + schemaUpdateFile = Paths.get(installScripts.getDataDir(), "upgrade", "2.2.0", SCHEMA_UPDATE_SQL); + loadSql(schemaUpdateFile, conn); + log.info("Schema updated."); + } + break; default: throw new RuntimeException("Unable to upgrade SQL database, unsupported fromVersion: " + fromVersion); } diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java new file mode 100644 index 0000000000..bfd371b28a --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.install; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.SqlDao; + +@Service +@SqlDao +@Profile("install") +public class SqlEntityDatabaseSchemaService extends SqlAbstractDatabaseSchemaService + implements EntityDatabaseSchemaService { + public SqlEntityDatabaseSchemaService() { + super("schema-entities.sql"); + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/session/SessionTerminationMsg.java b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java similarity index 58% rename from application/src/main/java/org/thingsboard/server/actors/session/SessionTerminationMsg.java rename to application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java index 38cf878fa7..03a35178bf 100644 --- a/application/src/main/java/org/thingsboard/server/actors/session/SessionTerminationMsg.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlTsDatabaseSchemaService.java @@ -13,14 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.actors.session; +package org.thingsboard.server.service.install; -import org.thingsboard.server.actors.shared.ActorTerminationMsg; -import org.thingsboard.server.common.data.id.SessionId; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.thingsboard.server.dao.util.SqlTsDao; -public class SessionTerminationMsg extends ActorTerminationMsg { - - public SessionTerminationMsg(SessionId id) { - super(id); +@Service +@SqlTsDao +@Profile("install") +public class SqlTsDatabaseSchemaService extends SqlAbstractDatabaseSchemaService + implements TsDatabaseSchemaService { + public SqlTsDatabaseSchemaService() { + super("schema-ts.sql"); } -} +} \ No newline at end of file diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/StatusCodeResponse.java b/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java similarity index 83% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/StatusCodeResponse.java rename to application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java index b2d8611d1f..0a2ba7586c 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/StatusCodeResponse.java +++ b/application/src/main/java/org/thingsboard/server/service/install/TsDatabaseSchemaService.java @@ -13,8 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; - -public interface StatusCodeResponse extends ResponseMsg{ +package org.thingsboard.server.service.install; +public interface TsDatabaseSchemaService extends DatabaseSchemaService { } diff --git a/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java b/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java index bdf980fea2..7c03a719c9 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java +++ b/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java @@ -147,6 +147,8 @@ public class CassandraDbHelper { str = new Double(row.getDouble(index)).toString(); } else if (type == DataType.cint()) { str = new Integer(row.getInt(index)).toString(); + } else if (type == DataType.bigint()) { + str = new Long(row.getLong(index)).toString(); } else if (type == DataType.uuid()) { str = row.getUUID(index).toString(); } else if (type == DataType.timeuuid()) { @@ -193,6 +195,8 @@ public class CassandraDbHelper { boundStatement.setDouble(column, Double.valueOf(value)); } else if (type == DataType.cint()) { boundStatement.setInt(column, Integer.valueOf(value)); + } else if (type == DataType.bigint()) { + boundStatement.setLong(column, Long.valueOf(value)); } else if (type == DataType.uuid()) { boundStatement.setUUID(column, UUID.fromString(value)); } else if (type == DataType.timeuuid()) { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java deleted file mode 100644 index 927584789d..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultMsgQueueService.java +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.queue; - -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.common.transport.quota.tenant.TenantQuotaService; -import org.thingsboard.server.dao.queue.MsgQueue; - -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -@Service -@Slf4j -public class DefaultMsgQueueService implements MsgQueueService { - - @Value("${actors.rule.queue.max_size}") - private long queueMaxSize; - - @Value("${actors.rule.queue.cleanup_period}") - private long queueCleanUpPeriod; - - @Autowired - private MsgQueue msgQueue; - - @Autowired - private TenantQuotaService quotaService; - - private ScheduledExecutorService cleanupExecutor; - - private Map pendingCountPerTenant = new ConcurrentHashMap<>(); - - @PostConstruct - public void init() { - if (queueCleanUpPeriod > 0) { - cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); - cleanupExecutor.scheduleAtFixedRate(() -> cleanup(), - queueCleanUpPeriod, queueCleanUpPeriod, TimeUnit.SECONDS); - } - } - - @PreDestroy - public void stop() { - if (cleanupExecutor != null) { - cleanupExecutor.shutdownNow(); - } - } - - @Override - public ListenableFuture put(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition) { - if(quotaService.isQuotaExceeded(tenantId.getId().toString())) { - log.warn("Tenant TbMsg Quota exceeded for [{}:{}] . Reject", tenantId.getId()); - return Futures.immediateFailedFuture(new RuntimeException("Tenant TbMsg Quota exceeded")); - } - - AtomicLong pendingMsgCount = pendingCountPerTenant.computeIfAbsent(tenantId, key -> new AtomicLong()); - if (pendingMsgCount.incrementAndGet() < queueMaxSize) { - return msgQueue.put(tenantId, msg, nodeId, clusterPartition); - } else { - pendingMsgCount.decrementAndGet(); - return Futures.immediateFailedFuture(new RuntimeException("Message queue is full!")); - } - } - - @Override - public ListenableFuture ack(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition) { - ListenableFuture result = msgQueue.ack(tenantId, msg, nodeId, clusterPartition); - AtomicLong pendingMsgCount = pendingCountPerTenant.computeIfAbsent(tenantId, key -> new AtomicLong()); - pendingMsgCount.decrementAndGet(); - return result; - } - - @Override - public Iterable findUnprocessed(TenantId tenantId, UUID nodeId, long clusterPartition) { - return msgQueue.findUnprocessed(tenantId, nodeId, clusterPartition); - } - - private void cleanup() { - pendingCountPerTenant.forEach((tenantId, pendingMsgCount) -> { - pendingMsgCount.set(0); - msgQueue.cleanUp(tenantId); - }); - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/MsgQueueService.java b/application/src/main/java/org/thingsboard/server/service/queue/MsgQueueService.java deleted file mode 100644 index 2cf001ba73..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/queue/MsgQueueService.java +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.queue; - -import com.google.common.util.concurrent.ListenableFuture; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; - -import java.util.UUID; - -public interface MsgQueueService { - - ListenableFuture put(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition); - - ListenableFuture ack(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition); - - Iterable findUnprocessed(TenantId tenantId, UUID nodeId, long clusterPartition); - -} diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java index 64f5213d65..a65c1a5771 100644 --- a/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/rpc/DefaultDeviceRpcService.java @@ -90,7 +90,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { @Override public void processRestAPIRpcRequestToRuleEngine(ToDeviceRpcRequest request, Consumer responseConsumer) { - log.trace("[{}] Processing local rpc call to rule engine [{}]", request.getTenantId(), request.getDeviceId()); + log.trace("[{}][{}] Processing REST API call to rule engine [{}]", request.getTenantId(), request.getId(), request.getDeviceId()); UUID requestId = request.getId(); localToRuleEngineRpcRequests.put(requestId, responseConsumer); sendRpcRequestToRuleEngine(request); @@ -98,31 +98,11 @@ public class DefaultDeviceRpcService implements DeviceRpcService { } @Override - public void processRestAPIRpcResponseFromRuleEngine(FromDeviceRpcResponse response) { - UUID requestId = response.getId(); - Consumer consumer = localToRuleEngineRpcRequests.remove(requestId); - if (consumer != null) { - consumer.accept(response); - } else { - log.trace("[{}] Unknown or stale rpc response received [{}]", requestId, response); - } - } - - @Override - public void processRpcRequestToDevice(ToDeviceRpcRequest request, Consumer responseConsumer) { - log.trace("[{}] Processing local rpc call to device [{}]", request.getTenantId(), request.getDeviceId()); - UUID requestId = request.getId(); - localToDeviceRpcRequests.put(requestId, responseConsumer); - sendRpcRequestToDevice(request); - scheduleTimeout(request, requestId, localToDeviceRpcRequests); - } - - @Override - public void processRpcResponseFromDevice(FromDeviceRpcResponse response) { - log.trace("[{}] response to request: [{}]", this.hashCode(), response.getId()); - if (routingService.getCurrentServer().equals(response.getServerAddress())) { + public void processResponseToServerSideRPCRequestFromRuleEngine(ServerAddress requestOriginAddress, FromDeviceRpcResponse response) { + log.trace("[{}] Received response to server-side RPC request from rule engine: [{}]", response.getId(), requestOriginAddress); + if (routingService.getCurrentServer().equals(requestOriginAddress)) { UUID requestId = response.getId(); - Consumer consumer = localToDeviceRpcRequests.remove(requestId); + Consumer consumer = localToRuleEngineRpcRequests.remove(requestId); if (consumer != null) { consumer.accept(response); } else { @@ -138,12 +118,33 @@ public class DefaultDeviceRpcService implements DeviceRpcService { } else { builder.setError(-1); } - rpcService.tell(response.getServerAddress(), ClusterAPIProtos.MessageType.CLUSTER_RPC_FROM_DEVICE_RESPONSE_MESSAGE, builder.build().toByteArray()); + rpcService.tell(requestOriginAddress, ClusterAPIProtos.MessageType.CLUSTER_RPC_FROM_DEVICE_RESPONSE_MESSAGE, builder.build().toByteArray()); + } + } + + @Override + public void forwardServerSideRPCRequestToDeviceActor(ToDeviceRpcRequest request, Consumer responseConsumer) { + log.trace("[{}][{}] Processing local rpc call to device actor [{}]", request.getTenantId(), request.getId(), request.getDeviceId()); + UUID requestId = request.getId(); + localToDeviceRpcRequests.put(requestId, responseConsumer); + sendRpcRequestToDevice(request); + scheduleTimeout(request, requestId, localToDeviceRpcRequests); + } + + @Override + public void processResponseToServerSideRPCRequestFromDeviceActor(FromDeviceRpcResponse response) { + log.trace("[{}] Received response to server-side RPC request from device actor.", response.getId()); + UUID requestId = response.getId(); + Consumer consumer = localToDeviceRpcRequests.remove(requestId); + if (consumer != null) { + consumer.accept(response); + } else { + log.trace("[{}] Unknown or stale rpc response received [{}]", requestId, response); } } @Override - public void processRemoteResponseFromDevice(ServerAddress serverAddress, byte[] data) { + public void processResponseToServerSideRPCRequestFromRemoteServer(ServerAddress serverAddress, byte[] data) { ClusterAPIProtos.FromDeviceRPCResponseProto proto; try { proto = ClusterAPIProtos.FromDeviceRPCResponseProto.parseFrom(data); @@ -151,13 +152,12 @@ public class DefaultDeviceRpcService implements DeviceRpcService { throw new RuntimeException(e); } RpcError error = proto.getError() > 0 ? RpcError.values()[proto.getError()] : null; - FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()), routingService.getCurrentServer(), - proto.getResponse(), error); - processRpcResponseFromDevice(response); + FromDeviceRpcResponse response = new FromDeviceRpcResponse(new UUID(proto.getRequestIdMSB(), proto.getRequestIdLSB()), proto.getResponse(), error); + processResponseToServerSideRPCRequestFromRuleEngine(routingService.getCurrentServer(), response); } @Override - public void sendRpcReplyToDevice(TenantId tenantId, DeviceId deviceId, int requestId, String body) { + public void sendReplyToRpcCallFromDevice(TenantId tenantId, DeviceId deviceId, int requestId, String body) { ToServerRpcResponseActorMsg rpcMsg = new ToServerRpcResponseActorMsg(tenantId, deviceId, new ToServerRpcResponseMsg(requestId, body)); forward(deviceId, rpcMsg); } @@ -166,6 +166,8 @@ public class DefaultDeviceRpcService implements DeviceRpcService { ObjectNode entityNode = json.createObjectNode(); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("requestUUID", msg.getId().toString()); + metaData.putValue("originHost", routingService.getCurrentServer().getHost()); + metaData.putValue("originPort", Integer.toString(routingService.getCurrentServer().getPort())); metaData.putValue("expirationTime", Long.toString(msg.getExpirationTime())); metaData.putValue("oneway", Boolean.toString(msg.isOneway())); @@ -176,7 +178,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), DataConstants.RPC_CALL_FROM_SERVER_TO_DEVICE, msg.getDeviceId(), metaData, TbMsgDataType.JSON , json.writeValueAsString(entityNode) , null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(msg.getTenantId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(msg.getDeviceId(), new ServiceToRuleEngineMsg(msg.getTenantId(), tbMsg))); } catch (JsonProcessingException e) { throw new RuntimeException(e); } @@ -199,7 +201,7 @@ public class DefaultDeviceRpcService implements DeviceRpcService { log.trace("[{}] timeout the request: [{}]", this.hashCode(), requestId); Consumer consumer = requestsMap.remove(requestId); if (consumer != null) { - consumer.accept(new FromDeviceRpcResponse(requestId, null, null, RpcError.TIMEOUT)); + consumer.accept(new FromDeviceRpcResponse(requestId, null, RpcError.TIMEOUT)); } }, timeout, TimeUnit.MILLISECONDS); } diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/DeviceRpcService.java b/application/src/main/java/org/thingsboard/server/service/rpc/DeviceRpcService.java index 4cee96ee71..bf12ec664c 100644 --- a/application/src/main/java/org/thingsboard/server/service/rpc/DeviceRpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/rpc/DeviceRpcService.java @@ -29,13 +29,13 @@ public interface DeviceRpcService { void processRestAPIRpcRequestToRuleEngine(ToDeviceRpcRequest request, Consumer responseConsumer); - void processRestAPIRpcResponseFromRuleEngine(FromDeviceRpcResponse response); + void processResponseToServerSideRPCRequestFromRuleEngine(ServerAddress requestOriginAddress, FromDeviceRpcResponse response); - void processRpcRequestToDevice(ToDeviceRpcRequest request, Consumer responseConsumer); + void forwardServerSideRPCRequestToDeviceActor(ToDeviceRpcRequest request, Consumer responseConsumer); - void processRpcResponseFromDevice(FromDeviceRpcResponse response); + void processResponseToServerSideRPCRequestFromDeviceActor(FromDeviceRpcResponse response); - void sendRpcReplyToDevice(TenantId tenantId, DeviceId deviceId, int requestId, String body); + void processResponseToServerSideRPCRequestFromRemoteServer(ServerAddress serverAddress, byte[] data); - void processRemoteResponseFromDevice(ServerAddress serverAddress, byte[] bytes); + void sendReplyToRpcCallFromDevice(TenantId tenantId, DeviceId deviceId, int requestId, String body); } diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponse.java b/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponse.java index 9c3ce9a834..75506dfacc 100644 --- a/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponse.java +++ b/application/src/main/java/org/thingsboard/server/service/rpc/FromDeviceRpcResponse.java @@ -32,8 +32,6 @@ import java.util.UUID; public class FromDeviceRpcResponse { @Getter private final UUID id; - @Getter - private final ServerAddress serverAddress; private final String response; private final RpcError error; diff --git a/application/src/main/java/org/thingsboard/server/service/rpc/ToServerRpcResponseActorMsg.java b/application/src/main/java/org/thingsboard/server/service/rpc/ToServerRpcResponseActorMsg.java index 201f65613a..d33a3380f4 100644 --- a/application/src/main/java/org/thingsboard/server/service/rpc/ToServerRpcResponseActorMsg.java +++ b/application/src/main/java/org/thingsboard/server/service/rpc/ToServerRpcResponseActorMsg.java @@ -22,11 +22,8 @@ import org.thingsboard.rule.engine.api.msg.ToDeviceActorNotificationMsg; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.core.ToServerRpcResponseMsg; -import java.util.Optional; - /** * Created by ashvayka on 16.04.18. */ diff --git a/application/src/main/java/org/thingsboard/server/service/script/AbstractJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/AbstractJsInvokeService.java new file mode 100644 index 0000000000..461b89447f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/AbstractJsInvokeService.java @@ -0,0 +1,102 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.script; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Created by ashvayka on 26.09.18. + */ +@Slf4j +public abstract class AbstractJsInvokeService implements JsInvokeService { + + protected Map scriptIdToNameMap = new ConcurrentHashMap<>(); + protected Map blackListedFunctions = new ConcurrentHashMap<>(); + + @Override + public ListenableFuture eval(JsScriptType scriptType, String scriptBody, String... argNames) { + UUID scriptId = UUID.randomUUID(); + String functionName = "invokeInternal_" + scriptId.toString().replace('-', '_'); + String jsScript = generateJsScript(scriptType, functionName, scriptBody, argNames); + return doEval(scriptId, functionName, jsScript); + } + + @Override + public ListenableFuture invokeFunction(UUID scriptId, Object... args) { + String functionName = scriptIdToNameMap.get(scriptId); + if (functionName == null) { + return Futures.immediateFailedFuture(new RuntimeException("No compiled script found for scriptId: [" + scriptId + "]!")); + } + if (!isBlackListed(scriptId)) { + return doInvokeFunction(scriptId, functionName, args); + } else { + return Futures.immediateFailedFuture( + new RuntimeException("Script is blacklisted due to maximum error count " + getMaxErrors() + "!")); + } + } + + @Override + public ListenableFuture release(UUID scriptId) { + String functionName = scriptIdToNameMap.get(scriptId); + if (functionName != null) { + try { + scriptIdToNameMap.remove(scriptId); + blackListedFunctions.remove(scriptId); + doRelease(scriptId, functionName); + } catch (Exception e) { + return Futures.immediateFailedFuture(e); + } + } + return Futures.immediateFuture(null); + } + + protected abstract ListenableFuture doEval(UUID scriptId, String functionName, String scriptBody); + + protected abstract ListenableFuture doInvokeFunction(UUID scriptId, String functionName, Object[] args); + + protected abstract void doRelease(UUID scriptId, String functionName) throws Exception; + + protected abstract int getMaxErrors(); + + protected void onScriptExecutionError(UUID scriptId) { + blackListedFunctions.computeIfAbsent(scriptId, key -> new AtomicInteger(0)).incrementAndGet(); + } + + private String generateJsScript(JsScriptType scriptType, String functionName, String scriptBody, String... argNames) { + switch (scriptType) { + case RULE_NODE_SCRIPT: + return RuleNodeScriptFactory.generateRuleNodeScript(functionName, scriptBody, argNames); + default: + throw new RuntimeException("No script factory implemented for scriptType: " + scriptType); + } + } + + private boolean isBlackListed(UUID scriptId) { + if (blackListedFunctions.containsKey(scriptId)) { + AtomicInteger errorCount = blackListedFunctions.get(scriptId); + return errorCount.get() >= getMaxErrors(); + } else { + return false; + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java new file mode 100644 index 0000000000..7c8eb2a89f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsInvokeService.java @@ -0,0 +1,109 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.script; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import delight.nashornsandbox.NashornSandbox; +import delight.nashornsandbox.NashornSandboxes; +import jdk.nashorn.api.scripting.NashornScriptEngineFactory; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import javax.script.Invocable; +import javax.script.ScriptEngine; +import javax.script.ScriptException; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@Slf4j +public abstract class AbstractNashornJsInvokeService extends AbstractJsInvokeService { + + private NashornSandbox sandbox; + private ScriptEngine engine; + private ExecutorService monitorExecutorService; + + @PostConstruct + public void init() { + if (useJsSandbox()) { + sandbox = NashornSandboxes.create(); + monitorExecutorService = Executors.newFixedThreadPool(getMonitorThreadPoolSize()); + sandbox.setExecutor(monitorExecutorService); + sandbox.setMaxCPUTime(getMaxCpuTime()); + sandbox.allowNoBraces(false); + sandbox.setMaxPreparedStatements(30); + } else { + NashornScriptEngineFactory factory = new NashornScriptEngineFactory(); + engine = factory.getScriptEngine(new String[]{"--no-java"}); + } + } + + @PreDestroy + public void stop() { + if (monitorExecutorService != null) { + monitorExecutorService.shutdownNow(); + } + } + + protected abstract boolean useJsSandbox(); + + protected abstract int getMonitorThreadPoolSize(); + + protected abstract long getMaxCpuTime(); + + @Override + protected ListenableFuture doEval(UUID scriptId, String functionName, String jsScript) { + try { + if (useJsSandbox()) { + sandbox.eval(jsScript); + } else { + engine.eval(jsScript); + } + scriptIdToNameMap.put(scriptId, functionName); + } catch (Exception e) { + log.warn("Failed to compile JS script: {}", e.getMessage(), e); + return Futures.immediateFailedFuture(e); + } + return Futures.immediateFuture(scriptId); + } + + @Override + protected ListenableFuture doInvokeFunction(UUID scriptId, String functionName, Object[] args) { + try { + Object result; + if (useJsSandbox()) { + result = sandbox.getSandboxedInvocable().invokeFunction(functionName, args); + } else { + result = ((Invocable) engine).invokeFunction(functionName, args); + } + return Futures.immediateFuture(result); + } catch (Exception e) { + onScriptExecutionError(scriptId); + return Futures.immediateFailedFuture(e); + } + } + + protected void doRelease(UUID scriptId, String functionName) throws ScriptException { + if (useJsSandbox()) { + sandbox.eval(functionName + " = undefined;"); + } else { + engine.eval(functionName + " = undefined;"); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java b/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java deleted file mode 100644 index 724f652609..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/script/AbstractNashornJsSandboxService.java +++ /dev/null @@ -1,188 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package org.thingsboard.server.service.script; - -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import delight.nashornsandbox.NashornSandbox; -import delight.nashornsandbox.NashornSandboxes; -import jdk.nashorn.api.scripting.NashornScriptEngineFactory; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.tuple.Pair; - -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; -import javax.script.Invocable; -import javax.script.ScriptEngine; -import javax.script.ScriptException; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.atomic.AtomicInteger; - -@Slf4j -public abstract class AbstractNashornJsSandboxService implements JsSandboxService { - - private NashornSandbox sandbox; - private ScriptEngine engine; - private ExecutorService monitorExecutorService; - - private final Map functionsMap = new ConcurrentHashMap<>(); - private final Map blackListedFunctions = new ConcurrentHashMap<>(); - private final Map> scriptToId = new ConcurrentHashMap<>(); - private final Map scriptIdToCount = new ConcurrentHashMap<>(); - - @PostConstruct - public void init() { - if (useJsSandbox()) { - sandbox = NashornSandboxes.create(); - monitorExecutorService = Executors.newFixedThreadPool(getMonitorThreadPoolSize()); - sandbox.setExecutor(monitorExecutorService); - sandbox.setMaxCPUTime(getMaxCpuTime()); - sandbox.allowNoBraces(false); - sandbox.setMaxPreparedStatements(30); - } else { - NashornScriptEngineFactory factory = new NashornScriptEngineFactory(); - engine = factory.getScriptEngine(new String[]{"--no-java"}); - } - } - - @PreDestroy - public void stop() { - if (monitorExecutorService != null) { - monitorExecutorService.shutdownNow(); - } - } - - protected abstract boolean useJsSandbox(); - - protected abstract int getMonitorThreadPoolSize(); - - protected abstract long getMaxCpuTime(); - - protected abstract int getMaxErrors(); - - @Override - public ListenableFuture eval(JsScriptType scriptType, String scriptBody, String... argNames) { - Pair deduplicated = deduplicate(scriptType, scriptBody); - UUID scriptId = deduplicated.getLeft(); - AtomicInteger duplicateCount = deduplicated.getRight(); - - if(duplicateCount.compareAndSet(0, 1)) { - String functionName = "invokeInternal_" + scriptId.toString().replace('-', '_'); - String jsScript = generateJsScript(scriptType, functionName, scriptBody, argNames); - try { - if (useJsSandbox()) { - sandbox.eval(jsScript); - } else { - engine.eval(jsScript); - } - functionsMap.put(scriptId, functionName); - } catch (Exception e) { - duplicateCount.decrementAndGet(); - log.warn("Failed to compile JS script: {}", e.getMessage(), e); - return Futures.immediateFailedFuture(e); - } - } else { - duplicateCount.incrementAndGet(); - } - return Futures.immediateFuture(scriptId); - } - - @Override - public ListenableFuture invokeFunction(UUID scriptId, Object... args) { - String functionName = functionsMap.get(scriptId); - if (functionName == null) { - return Futures.immediateFailedFuture(new RuntimeException("No compiled script found for scriptId: [" + scriptId + "]!")); - } - if (!isBlackListed(scriptId)) { - try { - Object result; - if (useJsSandbox()) { - result = sandbox.getSandboxedInvocable().invokeFunction(functionName, args); - } else { - result = ((Invocable)engine).invokeFunction(functionName, args); - } - return Futures.immediateFuture(result); - } catch (Exception e) { - blackListedFunctions.computeIfAbsent(scriptId, key -> new AtomicInteger(0)).incrementAndGet(); - return Futures.immediateFailedFuture(e); - } - } else { - return Futures.immediateFailedFuture( - new RuntimeException("Script is blacklisted due to maximum error count " + getMaxErrors() + "!")); - } - } - - @Override - public ListenableFuture release(UUID scriptId) { - AtomicInteger count = scriptIdToCount.get(scriptId); - if(count != null) { - if(count.decrementAndGet() > 0) { - return Futures.immediateFuture(null); - } - } - - String functionName = functionsMap.get(scriptId); - if (functionName != null) { - try { - if (useJsSandbox()) { - sandbox.eval(functionName + " = undefined;"); - } else { - engine.eval(functionName + " = undefined;"); - } - functionsMap.remove(scriptId); - blackListedFunctions.remove(scriptId); - } catch (ScriptException e) { - return Futures.immediateFailedFuture(e); - } - } - return Futures.immediateFuture(null); - } - - private boolean isBlackListed(UUID scriptId) { - if (blackListedFunctions.containsKey(scriptId)) { - AtomicInteger errorCount = blackListedFunctions.get(scriptId); - return errorCount.get() >= getMaxErrors(); - } else { - return false; - } - } - - private String generateJsScript(JsScriptType scriptType, String functionName, String scriptBody, String... argNames) { - switch (scriptType) { - case RULE_NODE_SCRIPT: - return RuleNodeScriptFactory.generateRuleNodeScript(functionName, scriptBody, argNames); - default: - throw new RuntimeException("No script factory implemented for scriptType: " + scriptType); - } - } - - private Pair deduplicate(JsScriptType scriptType, String scriptBody) { - Pair precomputed = Pair.of(UUID.randomUUID(), new AtomicInteger()); - - Pair pair = scriptToId.computeIfAbsent(deduplicateKey(scriptType, scriptBody), i -> precomputed); - AtomicInteger duplicateCount = scriptIdToCount.computeIfAbsent(pair.getLeft(), i -> pair.getRight()); - return Pair.of(pair.getLeft(), duplicateCount); - } - - private String deduplicateKey(JsScriptType scriptType, String scriptBody) { - return scriptType + "_" + scriptBody; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceMsg.java b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeRequest.java similarity index 72% rename from common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceMsg.java rename to application/src/main/java/org/thingsboard/server/service/script/JsInvokeRequest.java index 71b605712f..1041a26c75 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceMsg.java +++ b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeRequest.java @@ -13,12 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.session; +package org.thingsboard.server.service.script; -import java.io.Serializable; +import java.util.List; -public interface FromDeviceMsg extends Serializable { +/** + * Created by ashvayka on 25.09.18. + */ +public class JsInvokeRequest { - SessionMsgType getMsgType(); + private String scriptId; + private String scriptBody; + private List args; } diff --git a/application/src/main/java/org/thingsboard/server/service/script/JsInvokeResponse.java b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeResponse.java new file mode 100644 index 0000000000..1638de8417 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeResponse.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.script; + +import java.util.List; + +/** + * Created by ashvayka on 25.09.18. + */ +public class JsInvokeResponse { + + private String scriptId; + private String scriptBody; + private List args; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/JsSandboxService.java b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeService.java similarity index 91% rename from application/src/main/java/org/thingsboard/server/service/script/JsSandboxService.java rename to application/src/main/java/org/thingsboard/server/service/script/JsInvokeService.java index ee86c62a25..031a5ee657 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/JsSandboxService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/JsInvokeService.java @@ -17,10 +17,11 @@ package org.thingsboard.server.service.script; import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.id.EntityId; import java.util.UUID; -public interface JsSandboxService { +public interface JsInvokeService { ListenableFuture eval(JsScriptType scriptType, String scriptBody, String... argNames); diff --git a/application/src/main/java/org/thingsboard/server/service/script/NashornJsSandboxService.java b/application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java similarity index 75% rename from application/src/main/java/org/thingsboard/server/service/script/NashornJsSandboxService.java rename to application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java index 3e8b4e9c55..a50ab278da 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/NashornJsSandboxService.java +++ b/application/src/main/java/org/thingsboard/server/service/script/NashornJsInvokeService.java @@ -18,22 +18,24 @@ package org.thingsboard.server.service.script; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Service; @Slf4j +@ConditionalOnProperty(prefix = "js", value = "evaluator", havingValue = "local", matchIfMissing = true) @Service -public class NashornJsSandboxService extends AbstractNashornJsSandboxService { +public class NashornJsInvokeService extends AbstractNashornJsInvokeService { - @Value("${actors.rule.js_sandbox.use_js_sandbox}") + @Value("${js.local.use_js_sandbox}") private boolean useJsSandbox; - @Value("${actors.rule.js_sandbox.monitor_thread_pool_size}") + @Value("${js.local.monitor_thread_pool_size}") private int monitorThreadPoolSize; - @Value("${actors.rule.js_sandbox.max_cpu_time}") + @Value("${js.local.max_cpu_time}") private long maxCpuTime; - @Value("${actors.rule.js_sandbox.max_errors}") + @Value("${js.local.max_errors}") private int maxErrors; @Override diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java new file mode 100644 index 0000000000..edd7b3536c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsInvokeService.java @@ -0,0 +1,211 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.script; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; +import org.thingsboard.server.kafka.TBKafkaProducerTemplate; +import org.thingsboard.server.kafka.TbKafkaRequestTemplate; +import org.thingsboard.server.kafka.TbKafkaSettings; +import org.thingsboard.server.kafka.TbNodeIdProvider; +import org.thingsboard.server.service.cluster.discovery.DiscoveryService; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@ConditionalOnProperty(prefix = "js", value = "evaluator", havingValue = "remote", matchIfMissing = true) +@Service +public class RemoteJsInvokeService extends AbstractJsInvokeService { + + @Autowired + private TbNodeIdProvider nodeIdProvider; + + @Autowired + private TbKafkaSettings kafkaSettings; + + @Value("${js.remote.request_topic}") + private String requestTopic; + + @Value("${js.remote.response_topic_prefix}") + private String responseTopicPrefix; + + @Value("${js.remote.max_pending_requests}") + private long maxPendingRequests; + + @Value("${js.remote.max_requests_timeout}") + private long maxRequestsTimeout; + + @Value("${js.remote.response_poll_interval}") + private int responsePollDuration; + + @Value("${js.remote.response_auto_commit_interval}") + private int autoCommitInterval; + + @Getter + @Value("${js.remote.max_errors}") + private int maxErrors; + + private TbKafkaRequestTemplate kafkaTemplate; + private Map scriptIdToBodysMap = new ConcurrentHashMap<>(); + + @PostConstruct + public void init() { + TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder requestBuilder = TBKafkaProducerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.defaultTopic(requestTopic); + requestBuilder.encoder(new RemoteJsRequestEncoder()); + requestBuilder.enricher((request, responseTopic, requestId) -> { + JsInvokeProtos.RemoteJsRequest.Builder remoteRequest = JsInvokeProtos.RemoteJsRequest.newBuilder(); + if (request.hasCompileRequest()) { + remoteRequest.setCompileRequest(request.getCompileRequest()); + } + if (request.hasInvokeRequest()) { + remoteRequest.setInvokeRequest(request.getInvokeRequest()); + } + if (request.hasReleaseRequest()) { + remoteRequest.setReleaseRequest(request.getReleaseRequest()); + } + remoteRequest.setResponseTopic(responseTopic); + remoteRequest.setRequestIdMSB(requestId.getMostSignificantBits()); + remoteRequest.setRequestIdLSB(requestId.getLeastSignificantBits()); + return remoteRequest.build(); + }); + + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder responseBuilder = TBKafkaConsumerTemplate.builder(); + responseBuilder.settings(kafkaSettings); + responseBuilder.topic(responseTopicPrefix + "." + nodeIdProvider.getNodeId()); + responseBuilder.clientId("js-" + nodeIdProvider.getNodeId()); + responseBuilder.groupId("rule-engine-node-" + nodeIdProvider.getNodeId()); + responseBuilder.autoCommit(true); + responseBuilder.autoCommitIntervalMs(autoCommitInterval); + responseBuilder.decoder(new RemoteJsResponseDecoder()); + responseBuilder.requestIdExtractor((response) -> new UUID(response.getRequestIdMSB(), response.getRequestIdLSB())); + + TbKafkaRequestTemplate.TbKafkaRequestTemplateBuilder + builder = TbKafkaRequestTemplate.builder(); + builder.requestTemplate(requestBuilder.build()); + builder.responseTemplate(responseBuilder.build()); + builder.maxPendingRequests(maxPendingRequests); + builder.maxRequestTimeout(maxRequestsTimeout); + builder.pollInterval(responsePollDuration); + kafkaTemplate = builder.build(); + kafkaTemplate.init(); + } + + @PreDestroy + public void destroy() { + if (kafkaTemplate != null) { + kafkaTemplate.stop(); + } + } + + @Override + protected ListenableFuture doEval(UUID scriptId, String functionName, String scriptBody) { + JsInvokeProtos.JsCompileRequest jsRequest = JsInvokeProtos.JsCompileRequest.newBuilder() + .setScriptIdMSB(scriptId.getMostSignificantBits()) + .setScriptIdLSB(scriptId.getLeastSignificantBits()) + .setFunctionName(functionName) + .setScriptBody(scriptBody).build(); + + JsInvokeProtos.RemoteJsRequest jsRequestWrapper = JsInvokeProtos.RemoteJsRequest.newBuilder() + .setCompileRequest(jsRequest) + .build(); + + log.trace("Post compile request for scriptId [{}]", scriptId); + ListenableFuture future = kafkaTemplate.post(scriptId.toString(), jsRequestWrapper); + return Futures.transform(future, response -> { + JsInvokeProtos.JsCompileResponse compilationResult = response.getCompileResponse(); + UUID compiledScriptId = new UUID(compilationResult.getScriptIdMSB(), compilationResult.getScriptIdLSB()); + if (compilationResult.getSuccess()) { + scriptIdToNameMap.put(scriptId, functionName); + scriptIdToBodysMap.put(scriptId, scriptBody); + return compiledScriptId; + } else { + log.debug("[{}] Failed to compile script due to [{}]: {}", compiledScriptId, compilationResult.getErrorCode().name(), compilationResult.getErrorDetails()); + throw new RuntimeException(compilationResult.getErrorDetails()); + } + }); + } + + @Override + protected ListenableFuture doInvokeFunction(UUID scriptId, String functionName, Object[] args) { + String scriptBody = scriptIdToBodysMap.get(scriptId); + if (scriptBody == null) { + return Futures.immediateFailedFuture(new RuntimeException("No script body found for scriptId: [" + scriptId + "]!")); + } + JsInvokeProtos.JsInvokeRequest.Builder jsRequestBuilder = JsInvokeProtos.JsInvokeRequest.newBuilder() + .setScriptIdMSB(scriptId.getMostSignificantBits()) + .setScriptIdLSB(scriptId.getLeastSignificantBits()) + .setFunctionName(functionName) + .setTimeout((int) maxRequestsTimeout) + .setScriptBody(scriptIdToBodysMap.get(scriptId)); + + for (int i = 0; i < args.length; i++) { + jsRequestBuilder.addArgs(args[i].toString()); + } + + JsInvokeProtos.RemoteJsRequest jsRequestWrapper = JsInvokeProtos.RemoteJsRequest.newBuilder() + .setInvokeRequest(jsRequestBuilder.build()) + .build(); + + ListenableFuture future = kafkaTemplate.post(scriptId.toString(), jsRequestWrapper); + return Futures.transform(future, response -> { + JsInvokeProtos.JsInvokeResponse invokeResult = response.getInvokeResponse(); + if (invokeResult.getSuccess()) { + return invokeResult.getResult(); + } else { + log.debug("[{}] Failed to compile script due to [{}]: {}", scriptId, invokeResult.getErrorCode().name(), invokeResult.getErrorDetails()); + throw new RuntimeException(invokeResult.getErrorDetails()); + } + }); + } + + @Override + protected void doRelease(UUID scriptId, String functionName) throws Exception { + JsInvokeProtos.JsReleaseRequest jsRequest = JsInvokeProtos.JsReleaseRequest.newBuilder() + .setScriptIdMSB(scriptId.getMostSignificantBits()) + .setScriptIdLSB(scriptId.getLeastSignificantBits()) + .setFunctionName(functionName).build(); + + JsInvokeProtos.RemoteJsRequest jsRequestWrapper = JsInvokeProtos.RemoteJsRequest.newBuilder() + .setReleaseRequest(jsRequest) + .build(); + + ListenableFuture future = kafkaTemplate.post(scriptId.toString(), jsRequestWrapper); + JsInvokeProtos.RemoteJsResponse response = future.get(); + + JsInvokeProtos.JsReleaseResponse compilationResult = response.getReleaseResponse(); + UUID compiledScriptId = new UUID(compilationResult.getScriptIdMSB(), compilationResult.getScriptIdLSB()); + if (compilationResult.getSuccess()) { + scriptIdToBodysMap.remove(scriptId); + } else { + log.debug("[{}] Failed to release script due", compiledScriptId); + } + } + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicAdaptorToSessionActorMsg.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsRequestEncoder.java similarity index 62% rename from common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicAdaptorToSessionActorMsg.java rename to application/src/main/java/org/thingsboard/server/service/script/RemoteJsRequestEncoder.java index 21c28d25be..7d5d547b8c 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicAdaptorToSessionActorMsg.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsRequestEncoder.java @@ -13,20 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.session; +package org.thingsboard.server.service.script; -public class BasicAdaptorToSessionActorMsg extends BasicSessionMsg implements AdaptorToSessionActorMsg { - - private final FromDeviceMsg msg; - - public BasicAdaptorToSessionActorMsg(SessionContext ctx, FromDeviceMsg msg) { - super(ctx); - this.msg = msg; - } +import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.kafka.TbKafkaEncoder; +/** + * Created by ashvayka on 25.09.18. + */ +public class RemoteJsRequestEncoder implements TbKafkaEncoder { @Override - public FromDeviceMsg getMsg() { - return msg; + public byte[] encode(JsInvokeProtos.RemoteJsRequest value) { + return value.toByteArray(); } - } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RemoteJsResponseDecoder.java b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsResponseDecoder.java new file mode 100644 index 0000000000..1975988d2b --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/script/RemoteJsResponseDecoder.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.script; + +import org.thingsboard.server.gen.js.JsInvokeProtos; +import org.thingsboard.server.kafka.TbKafkaDecoder; + +import java.io.IOException; + +/** + * Created by ashvayka on 25.09.18. + */ +public class RemoteJsResponseDecoder implements TbKafkaDecoder { + + @Override + public JsInvokeProtos.RemoteJsResponse decode(byte[] data) throws IOException { + return JsInvokeProtos.RemoteJsResponse.parseFrom(data); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java index 2ba87ec8da..2a9805156f 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngine.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.collect.Sets; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -36,16 +37,22 @@ import java.util.concurrent.ExecutionException; public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.ScriptEngine { private static final ObjectMapper mapper = new ObjectMapper(); - private final JsSandboxService sandboxService; + private final JsInvokeService sandboxService; private final UUID scriptId; + private final EntityId entityId; - public RuleNodeJsScriptEngine(JsSandboxService sandboxService, String script, String... argNames) { + public RuleNodeJsScriptEngine(JsInvokeService sandboxService, EntityId entityId, String script, String... argNames) { this.sandboxService = sandboxService; + this.entityId = entityId; try { this.scriptId = this.sandboxService.eval(JsScriptType.RULE_NODE_SCRIPT, script, argNames).get(); } catch (Exception e) { - throw new IllegalArgumentException("Can't compile script: " + e.getMessage(), e); + Throwable t = e; + if (e instanceof ExecutionException) { + t = e.getCause(); + } + throw new IllegalArgumentException("Can't compile script: " + t.getMessage(), t); } } @@ -167,11 +174,13 @@ public class RuleNodeJsScriptEngine implements org.thingsboard.rule.engine.api.S } catch (ExecutionException e) { if (e.getCause() instanceof ScriptException) { throw (ScriptException)e.getCause(); + } else if (e.getCause() instanceof RuntimeException) { + throw new ScriptException(e.getCause().getMessage()); } else { - throw new ScriptException("Failed to execute js script: " + e.getMessage()); + throw new ScriptException(e); } } catch (Exception e) { - throw new ScriptException("Failed to execute js script: " + e.getMessage()); + throw new ScriptException(e); } } diff --git a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptFactory.java b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptFactory.java index 5cc9c55b6f..ef2b5aae15 100644 --- a/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptFactory.java +++ b/application/src/main/java/org/thingsboard/server/service/script/RuleNodeScriptFactory.java @@ -28,7 +28,7 @@ public class RuleNodeScriptFactory { " var metadata = JSON.parse(metadataStr); " + " return JSON.stringify(%s(msg, metadata, msgType));" + " function %s(%s, %s, %s) {"; - private static final String JS_WRAPPER_SUFFIX = "}" + + private static final String JS_WRAPPER_SUFFIX = "\n}" + "\n}"; diff --git a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java index 600820ec73..eaf1018ff2 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java +++ b/application/src/main/java/org/thingsboard/server/service/security/AccessValidator.java @@ -26,6 +26,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.context.request.async.DeferredResult; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -34,6 +35,7 @@ import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; @@ -44,6 +46,7 @@ import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.user.UserService; @@ -66,6 +69,7 @@ public class AccessValidator { public static final String CUSTOMER_USER_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION = "Customer user is not allowed to perform this operation!"; public static final String SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION = "System administrator is not allowed to perform this operation!"; public static final String DEVICE_WITH_REQUESTED_ID_NOT_FOUND = "Device with requested id wasn't found!"; + public static final String ENTITY_VIEW_WITH_REQUESTED_ID_NOT_FOUND = "Entity-view with requested id wasn't found!"; @Autowired protected TenantService tenantService; @@ -88,6 +92,9 @@ public class AccessValidator { @Autowired protected RuleChainService ruleChainService; + @Autowired + protected EntityViewService entityViewService; + private ExecutorService executor; @PostConstruct @@ -158,6 +165,9 @@ public class AccessValidator { case TENANT: validateTenant(currentUser, entityId, callback); return; + case ENTITY_VIEW: + validateEntityView(currentUser, entityId, callback); + return; default: //TODO: add support of other entities throw new IllegalStateException("Not Implemented!"); @@ -293,6 +303,27 @@ public class AccessValidator { } } + private void validateEntityView(final SecurityUser currentUser, EntityId entityId, FutureCallback callback) { + if (currentUser.isSystemAdmin()) { + callback.onSuccess(ValidationResult.accessDenied(SYSTEM_ADMINISTRATOR_IS_NOT_ALLOWED_TO_PERFORM_THIS_OPERATION)); + } else { + ListenableFuture entityViewFuture = entityViewService.findEntityViewByIdAsync(new EntityViewId(entityId.getId())); + Futures.addCallback(entityViewFuture, getCallback(callback, entityView -> { + if (entityView == null) { + return ValidationResult.entityNotFound(ENTITY_VIEW_WITH_REQUESTED_ID_NOT_FOUND); + } else { + if (!entityView.getTenantId().equals(currentUser.getTenantId())) { + return ValidationResult.accessDenied("Entity-view doesn't belong to the current Tenant!"); + } else if (currentUser.isCustomerUser() && !entityView.getCustomerId().equals(currentUser.getCustomerId())) { + return ValidationResult.accessDenied("Entity-view doesn't belong to the current Customer!"); + } else { + return ValidationResult.ok(entityView); + } + } + }), executor); + } + } + private FutureCallback getCallback(FutureCallback callback, Function> transformer) { return new FutureCallback() { @Override diff --git a/application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java b/application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java index 81ab9b4eb6..d4ac75374f 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java +++ b/application/src/main/java/org/thingsboard/server/service/security/ValidationCallback.java @@ -36,29 +36,10 @@ public class ValidationCallback implements FutureCallback { @Override public void onSuccess(ValidationResult result) { - ValidationResultCode resultCode = result.getResultCode(); - if (resultCode == ValidationResultCode.OK) { + if (result.getResultCode() == ValidationResultCode.OK) { action.onSuccess(response); } else { - Exception e; - switch (resultCode) { - case ENTITY_NOT_FOUND: - e = new EntityNotFoundException(result.getMessage()); - break; - case UNAUTHORIZED: - e = new UnauthorizedException(result.getMessage()); - break; - case ACCESS_DENIED: - e = new AccessDeniedException(result.getMessage()); - break; - case INTERNAL_ERROR: - e = new InternalErrorException(result.getMessage()); - break; - default: - e = new UnauthorizedException("Permission denied."); - break; - } - onFailure(e); + onFailure(getException(result)); } } @@ -66,4 +47,28 @@ public class ValidationCallback implements FutureCallback { public void onFailure(Throwable e) { action.onFailure(e); } + + public static Exception getException(ValidationResult result) { + ValidationResultCode resultCode = result.getResultCode(); + Exception e; + switch (resultCode) { + case ENTITY_NOT_FOUND: + e = new EntityNotFoundException(result.getMessage()); + break; + case UNAUTHORIZED: + e = new UnauthorizedException(result.getMessage()); + break; + case ACCESS_DENIED: + e = new AccessDeniedException(result.getMessage()); + break; + case INTERNAL_ERROR: + e = new InternalErrorException(result.getMessage()); + break; + default: + e = new UnauthorizedException("Permission denied."); + break; + } + return e; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java index 544b6fdea4..68d9ada212 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java +++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/RawAccessJwtToken.java @@ -22,6 +22,7 @@ import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.SignatureException; import io.jsonwebtoken.UnsupportedJwtException; +import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.security.authentication.BadCredentialsException; @@ -29,12 +30,11 @@ import org.thingsboard.server.service.security.exception.JwtExpiredTokenExceptio import java.io.Serializable; +@Slf4j public class RawAccessJwtToken implements JwtToken, Serializable { private static final long serialVersionUID = -797397445703066079L; - private static Logger logger = LoggerFactory.getLogger(RawAccessJwtToken.class); - private String token; public RawAccessJwtToken(String token) { @@ -52,10 +52,10 @@ public class RawAccessJwtToken implements JwtToken, Serializable { try { return Jwts.parser().setSigningKey(signingKey).parseClaimsJws(this.token); } catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) { - logger.error("Invalid JWT Token", ex); + log.error("Invalid JWT Token", ex); throw new BadCredentialsException("Invalid JWT token: ", ex); } catch (ExpiredJwtException expiredEx) { - logger.info("JWT Token is expired", expiredEx); + log.info("JWT Token is expired", expiredEx); throw new JwtExpiredTokenException(this, "JWT Token expired", expiredEx); } } 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 new file mode 100644 index 0000000000..6201dabdda --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/session/DefaultDeviceSessionCacheService.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.session; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CachePut; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceSessionsCacheEntry; + +import java.util.ArrayList; +import java.util.Collections; + +import static org.thingsboard.server.common.data.CacheConstants.SESSIONS_CACHE; + +/** + * Created by ashvayka on 29.10.18. + */ +@Service +@Slf4j +public class DefaultDeviceSessionCacheService implements DeviceSessionCacheService { + + @Override + @Cacheable(cacheNames = SESSIONS_CACHE, key = "#deviceId") + public DeviceSessionsCacheEntry get(DeviceId deviceId) { + log.debug("[{}] Fetching session data from cache", deviceId); + return DeviceSessionsCacheEntry.newBuilder().addAllSessions(Collections.emptyList()).build(); + } + + @Override + @CachePut(cacheNames = SESSIONS_CACHE, key = "#deviceId") + public DeviceSessionsCacheEntry put(DeviceId deviceId, DeviceSessionsCacheEntry sessions) { + log.debug("[{}] Pushing session data from cache: {}", deviceId, sessions); + return sessions; + } +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicRequest.java b/application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java similarity index 60% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicRequest.java rename to application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java index 4fcde4456b..0a2e6a52e5 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicRequest.java +++ b/application/src/main/java/org/thingsboard/server/service/session/DeviceSessionCacheService.java @@ -13,24 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; +package org.thingsboard.server.service.session; -import java.io.Serializable; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceSessionsCacheEntry; /** - * @author Andrew Shvayka + * Created by ashvayka on 29.10.18. */ -public class BasicRequest implements Serializable { +public interface DeviceSessionCacheService { - public static final Integer DEFAULT_REQUEST_ID = 0; + DeviceSessionsCacheEntry get(DeviceId deviceId); - private final Integer requestId; + DeviceSessionsCacheEntry put(DeviceId deviceId, DeviceSessionsCacheEntry sessions); - public BasicRequest(Integer requestId) { - this.requestId = requestId; - } - - public Integer getRequestId() { - return requestId; - } } diff --git a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java index f4e37db971..610cd3b72a 100644 --- a/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/state/DefaultDeviceStateService.java @@ -43,6 +43,7 @@ import org.thingsboard.server.common.data.plugin.ComponentLifecycleState; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.dao.attributes.AttributesService; @@ -457,7 +458,7 @@ public class DefaultDeviceStateService implements DeviceStateService { TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), msgType, stateData.getDeviceId(), stateData.getMetaData().copy(), TbMsgDataType.JSON , json.writeValueAsString(state) , null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(stateData.getTenantId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(stateData.getDeviceId(), new ServiceToRuleEngineMsg(stateData.getTenantId(), tbMsg))); } catch (Exception e) { log.warn("[{}] Failed to push inactivity alarm: {}", stateData.getDeviceId(), state, e); } diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java index 548c41739e..7c772420ba 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/DefaultTelemetrySubscriptionService.java @@ -29,9 +29,11 @@ import org.thingsboard.rule.engine.api.util.DonAsynchron; import org.thingsboard.server.actors.service.ActorService; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; @@ -48,6 +50,8 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.cluster.ServerAddress; import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.entityview.EntityViewService; +import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.gen.cluster.ClusterAPIProtos; import org.thingsboard.server.service.cluster.routing.ClusterRoutingService; @@ -64,6 +68,7 @@ import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; @@ -101,6 +106,9 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio @Autowired private ClusterRpcService rpcService; + @Autowired + private EntityViewService entityViewService; + @Autowired @Lazy private DeviceStateService stateService; @@ -133,20 +141,41 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio @Override public void addLocalWsSubscription(String sessionId, EntityId entityId, SubscriptionState sub) { + long startTime = 0L; + long endTime = 0L; + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW) && TelemetryFeature.TIMESERIES.equals(sub.getType())) { + EntityView entityView = entityViewService.findEntityViewById(new EntityViewId(entityId.getId())); + entityId = entityView.getEntityId(); + startTime = entityView.getStartTimeMs(); + endTime = entityView.getEndTimeMs(); + sub = getUpdatedSubscriptionState(entityId, sub, entityView); + } Optional server = routingService.resolveById(entityId); Subscription subscription; if (server.isPresent()) { ServerAddress address = server.get(); - log.trace("[{}] Forwarding subscription [{}] for device [{}] to [{}]", sessionId, sub.getSubscriptionId(), entityId, address); - subscription = new Subscription(sub, true, address); + log.trace("[{}] Forwarding subscription [{}] for [{}] entity [{}] to [{}]", sessionId, sub.getSubscriptionId(), entityId.getEntityType().name(), entityId, address); + subscription = new Subscription(sub, true, address, startTime, endTime); tellNewSubscription(address, sessionId, subscription); } else { - log.trace("[{}] Registering local subscription [{}] for device [{}]", sessionId, sub.getSubscriptionId(), entityId); - subscription = new Subscription(sub, true); + log.trace("[{}] Registering local subscription [{}] for [{}] entity [{}]", sessionId, sub.getSubscriptionId(), entityId.getEntityType().name(), entityId); + subscription = new Subscription(sub, true, null, startTime, endTime); } registerSubscription(sessionId, entityId, subscription); } + private SubscriptionState getUpdatedSubscriptionState(EntityId entityId, SubscriptionState sub, EntityView entityView) { + Map keyStates; + if (sub.isAllKeys()) { + keyStates = entityView.getKeys().getTimeseries().stream().collect(Collectors.toMap(k -> k, k -> 0L)); + } else { + keyStates = sub.getKeyStates().entrySet() + .stream().filter(entry -> entityView.getKeys().getTimeseries().contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + return new SubscriptionState(sub.getWsSessionId(), sub.getSubscriptionId(), entityId, sub.getType(), false, keyStates, sub.getScope()); + } + @Override public void cleanupLocalWsSessionSubscriptions(TelemetryWebSocketSessionRef sessionRef, String sessionId) { cleanupLocalWsSessionSubscriptions(sessionId); @@ -232,7 +261,7 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio new SubscriptionState(proto.getSessionId(), proto.getSubscriptionId(), EntityIdFactory.getByTypeAndId(proto.getEntityType(), proto.getEntityId()), TelemetryFeature.valueOf(proto.getType()), proto.getAllKeys(), statesMap, proto.getScope()), - false, new ServerAddress(serverAddress.getHost(), serverAddress.getPort())); + false, new ServerAddress(serverAddress.getHost(), serverAddress.getPort(), serverAddress.getServerType())); addRemoteWsSubscription(serverAddress, proto.getSessionId(), subscription); } @@ -430,7 +459,7 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio onLocalSubUpdate(entityId, s -> TelemetryFeature.TIMESERIES == s.getType(), s -> { List subscriptionUpdate = null; for (TsKvEntry kv : ts) { - if (s.isAllKeys() || s.getKeyStates().containsKey((kv.getKey()))) { + if (isInTimeRange(s, kv.getTs()) && (s.isAllKeys() || s.getKeyStates().containsKey((kv.getKey())))) { if (subscriptionUpdate == null) { subscriptionUpdate = new ArrayList<>(); } @@ -441,6 +470,11 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio }); } + private boolean isInTimeRange(Subscription subscription, long kvTime) { + return (subscription.getStartTime() == 0 || subscription.getStartTime() <= kvTime) + && (subscription.getEndTime() == 0 || subscription.getEndTime() >= kvTime); + } + private void onLocalSubUpdate(EntityId entityId, Predicate filter, Function> f) { Set deviceSubscriptions = subscriptionsByEntityId.get(entityId); if (deviceSubscriptions != null) { @@ -584,7 +618,9 @@ public class DefaultTelemetrySubscriptionService implements TelemetrySubscriptio builder.setEntityId(sub.getEntityId().getId().toString()); builder.setType(sub.getType().name()); builder.setAllKeys(sub.isAllKeys()); - builder.setScope(sub.getScope()); + if (sub.getScope() != null) { + builder.setScope(sub.getScope()); + } sub.getKeyStates().entrySet().forEach(e -> builder.addKeyStates( ClusterAPIProtos.SubscriptionKetStateProto.newBuilder().setKey(e.getKey()).setTs(e.getValue()).build())); rpcService.tell(address, ClusterAPIProtos.MessageType.CLUSTER_TELEMETRY_SUBSCRIPTION_CREATE_MESSAGE, builder.build().toByteArray()); 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 2ff8e89070..1251ca3b7e 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 @@ -37,13 +37,18 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.service.security.AccessValidator; +import org.thingsboard.server.service.security.ValidationCallback; import org.thingsboard.server.service.security.ValidationResult; +import org.thingsboard.server.service.security.ValidationResultCode; import org.thingsboard.server.service.telemetry.cmd.AttributesSubscriptionCmd; import org.thingsboard.server.service.telemetry.cmd.GetHistoryCmd; import org.thingsboard.server.service.telemetry.cmd.SubscriptionCmd; import org.thingsboard.server.service.telemetry.cmd.TelemetryPluginCmd; import org.thingsboard.server.service.telemetry.cmd.TelemetryPluginCmdsWrapper; import org.thingsboard.server.service.telemetry.cmd.TimeseriesSubscriptionCmd; +import org.thingsboard.server.service.telemetry.exception.AccessDeniedException; +import org.thingsboard.server.service.telemetry.exception.EntityNotFoundException; +import org.thingsboard.server.service.telemetry.exception.InternalErrorException; import org.thingsboard.server.service.telemetry.exception.UnauthorizedException; import org.thingsboard.server.service.telemetry.sub.SubscriptionErrorCode; import org.thingsboard.server.service.telemetry.sub.SubscriptionState; @@ -535,11 +540,16 @@ public class DefaultTelemetryWebSocketService implements TelemetryWebSocketServi }; } - private FutureCallback on(Consumer success, Consumer failure) { + private FutureCallback on(Consumer success, Consumer failure) { return new FutureCallback() { @Override public void onSuccess(@Nullable ValidationResult result) { - success.accept(result); + ValidationResultCode resultCode = result.getResultCode(); + if (resultCode == ValidationResultCode.OK) { + success.accept(null); + } else { + onFailure(ValidationCallback.getException(result)); + } } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java index 811c055372..32d6243a61 100644 --- a/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java +++ b/application/src/main/java/org/thingsboard/server/service/telemetry/sub/Subscription.java @@ -30,9 +30,11 @@ public class Subscription { private final SubscriptionState sub; private final boolean local; private ServerAddress server; + private long startTime; + private long endTime; - public Subscription(SubscriptionState sub, boolean local) { - this(sub, local, null); + public Subscription(SubscriptionState sub, boolean local, ServerAddress server) { + this(sub, local, server, 0L, 0L); } public String getWsSessionId() { diff --git a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportApiService.java new file mode 100644 index 0000000000..5c39f8af4d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportApiService.java @@ -0,0 +1,173 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.relation.RelationService; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; +import org.thingsboard.server.kafka.TBKafkaProducerTemplate; +import org.thingsboard.server.kafka.TbKafkaResponseTemplate; +import org.thingsboard.server.kafka.TbKafkaSettings; +import org.thingsboard.server.service.cluster.discovery.DiscoveryService; +import org.thingsboard.server.service.executors.DbCallbackExecutorService; +import org.thingsboard.server.service.state.DeviceStateService; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Created by ashvayka on 05.10.18. + */ +@Slf4j +@Service +public class LocalTransportApiService implements TransportApiService { + + private static final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private DeviceService deviceService; + + @Autowired + private RelationService relationService; + + @Autowired + private DeviceCredentialsService deviceCredentialsService; + + @Autowired + private DeviceStateService deviceStateService; + + @Autowired + private DbCallbackExecutorService dbCallbackExecutorService; + + private ReentrantLock deviceCreationLock = new ReentrantLock(); + + @Override + public ListenableFuture handle(TransportApiRequestMsg transportApiRequestMsg) { + if (transportApiRequestMsg.hasValidateTokenRequestMsg()) { + ValidateDeviceTokenRequestMsg msg = transportApiRequestMsg.getValidateTokenRequestMsg(); + return validateCredentials(msg.getToken(), DeviceCredentialsType.ACCESS_TOKEN); + } else if (transportApiRequestMsg.hasValidateX509CertRequestMsg()) { + ValidateDeviceX509CertRequestMsg msg = transportApiRequestMsg.getValidateX509CertRequestMsg(); + return validateCredentials(msg.getHash(), DeviceCredentialsType.X509_CERTIFICATE); + } else if (transportApiRequestMsg.hasGetOrCreateDeviceRequestMsg()) { + return handle(transportApiRequestMsg.getGetOrCreateDeviceRequestMsg()); + } + return getEmptyTransportApiResponseFuture(); + } + + private ListenableFuture validateCredentials(String credentialsId, DeviceCredentialsType credentialsType) { + //TODO: Make async and enable caching + DeviceCredentials credentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(credentialsId); + if (credentials != null && credentials.getCredentialsType() == credentialsType) { + return getDeviceInfo(credentials.getDeviceId()); + } else { + return getEmptyTransportApiResponseFuture(); + } + } + + private ListenableFuture handle(GetOrCreateDeviceFromGatewayRequestMsg requestMsg) { + DeviceId gatewayId = new DeviceId(new UUID(requestMsg.getGatewayIdMSB(), requestMsg.getGatewayIdLSB())); + ListenableFuture gatewayFuture = deviceService.findDeviceByIdAsync(gatewayId); + return Futures.transform(gatewayFuture, gateway -> { + deviceCreationLock.lock(); + try { + Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), requestMsg.getDeviceName()); + if (device == null) { + device = new Device(); + device.setTenantId(gateway.getTenantId()); + device.setName(requestMsg.getDeviceName()); + device.setType(requestMsg.getDeviceType()); + device.setCustomerId(gateway.getCustomerId()); + device = deviceService.saveDevice(device); + relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); + deviceStateService.onDeviceAdded(device); + } + return TransportApiResponseMsg.newBuilder() + .setGetOrCreateDeviceResponseMsg(GetOrCreateDeviceFromGatewayResponseMsg.newBuilder().setDeviceInfo(getDeviceInfoProto(device)).build()).build(); + } catch (JsonProcessingException e) { + log.warn("[{}] Failed to lookup device by gateway id and name", gatewayId, requestMsg.getDeviceName(), e); + throw new RuntimeException(e); + } finally { + deviceCreationLock.unlock(); + } + }, dbCallbackExecutorService); + } + + + private ListenableFuture getDeviceInfo(DeviceId deviceId) { + return Futures.transform(deviceService.findDeviceByIdAsync(deviceId), device -> { + if (device == null) { + log.trace("[{}] Failed to lookup device by id", deviceId); + return getEmptyTransportApiResponse(); + } + try { + return TransportApiResponseMsg.newBuilder() + .setValidateTokenResponseMsg(ValidateDeviceCredentialsResponseMsg.newBuilder().setDeviceInfo(getDeviceInfoProto(device)).build()).build(); + } catch (JsonProcessingException e) { + log.warn("[{}] Failed to lookup device by id", deviceId, e); + return getEmptyTransportApiResponse(); + } + }); + } + + private DeviceInfoProto getDeviceInfoProto(Device device) throws JsonProcessingException { + return DeviceInfoProto.newBuilder() + .setTenantIdMSB(device.getTenantId().getId().getMostSignificantBits()) + .setTenantIdLSB(device.getTenantId().getId().getLeastSignificantBits()) + .setDeviceIdMSB(device.getId().getId().getMostSignificantBits()) + .setDeviceIdLSB(device.getId().getId().getLeastSignificantBits()) + .setDeviceName(device.getName()) + .setDeviceType(device.getType()) + .setAdditionalInfo(mapper.writeValueAsString(device.getAdditionalInfo())) + .build(); + } + + private ListenableFuture getEmptyTransportApiResponseFuture() { + return Futures.immediateFuture(getEmptyTransportApiResponse()); + } + + private TransportApiResponseMsg getEmptyTransportApiResponse() { + return TransportApiResponseMsg.newBuilder() + .setValidateTokenResponseMsg(ValidateDeviceCredentialsResponseMsg.getDefaultInstance()).build(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java new file mode 100644 index 0000000000..3d12938903 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/LocalTransportService.java @@ -0,0 +1,214 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import akka.actor.ActorRef; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.rule.engine.api.util.DonAsynchron; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.common.transport.service.AbstractTransportService; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToRPCMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.service.cluster.routing.ClusterRoutingService; +import org.thingsboard.server.service.cluster.rpc.ClusterRpcService; +import org.thingsboard.server.service.encoding.DataDecodingEncodingService; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Created by ashvayka on 12.10.18. + */ +@Slf4j +@Service +@ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "local") +public class LocalTransportService extends AbstractTransportService implements RuleEngineTransportService { + + @Autowired + private TransportApiService transportApiService; + + @Autowired + private ActorSystemContext actorContext; + + //TODO: completely replace this routing with the Kafka routing by partition ids. + @Autowired + private ClusterRoutingService routingService; + @Autowired + private ClusterRpcService rpcService; + @Autowired + private DataDecodingEncodingService encodingService; + + @PostConstruct + public void init() { + super.init(); + } + + @PreDestroy + public void destroy() { + super.destroy(); + } + + @Override + public void process(ValidateDeviceTokenRequestMsg msg, TransportServiceCallback callback) { + DonAsynchron.withCallback( + transportApiService.handle(TransportApiRequestMsg.newBuilder().setValidateTokenRequestMsg(msg).build()), + transportApiResponseMsg -> { + if (callback != null) { + callback.onSuccess(transportApiResponseMsg.getValidateTokenResponseMsg()); + } + }, + getThrowableConsumer(callback), transportCallbackExecutor); + } + + @Override + public void process(ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { + DonAsynchron.withCallback( + transportApiService.handle(TransportApiRequestMsg.newBuilder().setValidateX509CertRequestMsg(msg).build()), + transportApiResponseMsg -> { + if (callback != null) { + callback.onSuccess(transportApiResponseMsg.getValidateTokenResponseMsg()); + } + }, + getThrowableConsumer(callback), transportCallbackExecutor); + } + + @Override + public void process(GetOrCreateDeviceFromGatewayRequestMsg msg, TransportServiceCallback callback) { + DonAsynchron.withCallback( + transportApiService.handle(TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(msg).build()), + transportApiResponseMsg -> { + if (callback != null) { + callback.onSuccess(transportApiResponseMsg.getGetOrCreateDeviceResponseMsg()); + } + }, + getThrowableConsumer(callback), transportCallbackExecutor); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSessionEvent(msg).build(), callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostTelemetry(msg).build(), callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setPostAttributes(msg).build(), callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setGetAttributes(msg).build(), callback); + } + + @Override + public void process(SessionInfoProto sessionInfo, TransportProtos.SubscriptionInfoProto msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscriptionInfo(msg).build(), callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToAttributes(msg).build(), callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setSubscribeToRPC(msg).build(), callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToDeviceRPCCallResponse(msg).build(), callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback) { + forwardToDeviceActor(TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo).setToServerRPCCallRequest(msg).build(), callback); + } + + @Override + public void process(String nodeId, DeviceActorToTransportMsg msg) { + process(nodeId, msg, null, null); + } + + @Override + public void process(String nodeId, DeviceActorToTransportMsg msg, Runnable onSuccess, Consumer onFailure) { + processToTransportMsg(msg); + if (onSuccess != null) { + onSuccess.run(); + } + } + + private void forwardToDeviceActor(TransportToDeviceActorMsg toDeviceActorMsg, TransportServiceCallback callback) { + TransportToDeviceActorMsgWrapper wrapper = new TransportToDeviceActorMsgWrapper(toDeviceActorMsg); + Optional address = routingService.resolveById(wrapper.getDeviceId()); + if (address.isPresent()) { + rpcService.tell(encodingService.convertToProtoDataMessage(address.get(), wrapper)); + } else { + actorContext.getAppActor().tell(wrapper, ActorRef.noSender()); + } + if (callback != null) { + callback.onSuccess(null); + } + } + + private Consumer getThrowableConsumer(TransportServiceCallback callback) { + return e -> { + if (callback != null) { + callback.onError(e); + } + }; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java new file mode 100644 index 0000000000..a8ab2cd625 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteRuleEngineTransportService.java @@ -0,0 +1,232 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import akka.actor.ActorRef; +import com.fasterxml.jackson.databind.ObjectMapper; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BlockingBucket; +import io.github.bucket4j.Bucket4j; +import io.github.bucket4j.local.LocalBucket; +import io.github.bucket4j.local.LocalBucketBuilder; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.actors.service.ActorService; +import org.thingsboard.server.common.msg.cluster.ServerAddress; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; +import org.thingsboard.server.kafka.TBKafkaConsumerTemplate; +import org.thingsboard.server.kafka.TBKafkaProducerTemplate; +import org.thingsboard.server.kafka.TbKafkaSettings; +import org.thingsboard.server.kafka.TbNodeIdProvider; +import org.thingsboard.server.service.cluster.discovery.DiscoveryService; +import org.thingsboard.server.service.cluster.routing.ClusterRoutingService; +import org.thingsboard.server.service.cluster.rpc.ClusterRpcService; +import org.thingsboard.server.service.encoding.DataDecodingEncodingService; +import org.thingsboard.server.service.transport.msg.TransportToDeviceActorMsgWrapper; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.time.Duration; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Created by ashvayka on 09.10.18. + */ +@Slf4j +@Service +@ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "remote") +public class RemoteRuleEngineTransportService implements RuleEngineTransportService { + + @Value("${transport.remote.rule_engine.topic}") + private String ruleEngineTopic; + @Value("${transport.remote.notifications.topic}") + private String notificationsTopic; + @Value("${transport.remote.rule_engine.poll_interval}") + private int pollDuration; + @Value("${transport.remote.rule_engine.auto_commit_interval}") + private int autoCommitInterval; + + @Value("${transport.remote.rule_engine.poll_records_pack_size}") + private int pollRecordsPackSize; + @Value("${transport.remote.rule_engine.max_poll_records_per_second}") + private long pollRecordsPerSecond; + @Value("${transport.remote.rule_engine.max_poll_records_per_minute}") + private long pollRecordsPerMinute; + + @Autowired + private TbKafkaSettings kafkaSettings; + + @Autowired + private TbNodeIdProvider nodeIdProvider; + + @Autowired + private ActorSystemContext actorContext; + + //TODO: completely replace this routing with the Kafka routing by partition ids. + @Autowired + private ClusterRoutingService routingService; + @Autowired + private ClusterRpcService rpcService; + @Autowired + private DataDecodingEncodingService encodingService; + + private TBKafkaConsumerTemplate ruleEngineConsumer; + private TBKafkaProducerTemplate notificationsProducer; + + private ExecutorService mainConsumerExecutor = Executors.newSingleThreadExecutor(); + + private volatile boolean stopped = false; + + @PostConstruct + public void init() { + TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder notificationsProducerBuilder = TBKafkaProducerTemplate.builder(); + notificationsProducerBuilder.settings(kafkaSettings); + notificationsProducerBuilder.defaultTopic(notificationsTopic); + notificationsProducerBuilder.encoder(new ToTransportMsgEncoder()); + + notificationsProducer = notificationsProducerBuilder.build(); + notificationsProducer.init(); + + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder ruleEngineConsumerBuilder = TBKafkaConsumerTemplate.builder(); + ruleEngineConsumerBuilder.settings(kafkaSettings); + ruleEngineConsumerBuilder.topic(ruleEngineTopic); + ruleEngineConsumerBuilder.clientId("transport-" + nodeIdProvider.getNodeId()); + ruleEngineConsumerBuilder.groupId("tb-node"); + ruleEngineConsumerBuilder.autoCommit(true); + ruleEngineConsumerBuilder.autoCommitIntervalMs(autoCommitInterval); + ruleEngineConsumerBuilder.maxPollRecords(pollRecordsPackSize); + ruleEngineConsumerBuilder.decoder(new ToRuleEngineMsgDecoder()); + + ruleEngineConsumer = ruleEngineConsumerBuilder.build(); + ruleEngineConsumer.subscribe(); + + LocalBucketBuilder builder = Bucket4j.builder(); + builder.addLimit(Bandwidth.simple(pollRecordsPerSecond, Duration.ofSeconds(1))); + builder.addLimit(Bandwidth.simple(pollRecordsPerMinute, Duration.ofMinutes(1))); + LocalBucket pollRateBucket = builder.build(); + BlockingBucket blockingPollRateBucket = pollRateBucket.asScheduler(); + + mainConsumerExecutor.execute(() -> { + while (!stopped) { + try { + ConsumerRecords records = ruleEngineConsumer.poll(Duration.ofMillis(pollDuration)); + int recordsCount = records.count(); + if (recordsCount > 0) { + while (!blockingPollRateBucket.tryConsume(recordsCount, TimeUnit.SECONDS.toNanos(5))) { + log.info("Rule Engine consumer is busy. Required tokens: [{}]. Available tokens: [{}].", recordsCount, pollRateBucket.getAvailableTokens()); + Thread.sleep(TimeUnit.SECONDS.toMillis(1)); + } + log.trace("Processing {} records", recordsCount); + } + records.forEach(record -> { + try { + ToRuleEngineMsg toRuleEngineMsg = ruleEngineConsumer.decode(record); + log.trace("Forwarding message to rule engine {}", toRuleEngineMsg); + if (toRuleEngineMsg.hasToDeviceActorMsg()) { + forwardToDeviceActor(toRuleEngineMsg.getToDeviceActorMsg()); + } + } catch (Throwable e) { + log.warn("Failed to process the notification.", e); + } + }); + } catch (Exception e) { + log.warn("Failed to obtain messages from queue.", e); + try { + Thread.sleep(pollDuration); + } catch (InterruptedException e2) { + log.trace("Failed to wait until the server has capacity to handle new requests", e2); + } + } + } + }); + } + + @Override + public void process(String nodeId, DeviceActorToTransportMsg msg) { + process(nodeId, msg, null, null); + } + + @Override + public void process(String nodeId, DeviceActorToTransportMsg msg, Runnable onSuccess, Consumer onFailure) { + String topic = notificationsTopic + "." + nodeId; + UUID sessionId = new UUID(msg.getSessionIdMSB(), msg.getSessionIdLSB()); + ToTransportMsg transportMsg = ToTransportMsg.newBuilder().setToDeviceSessionMsg(msg).build(); + log.trace("[{}][{}] Pushing session data to topic: {}", topic, sessionId, transportMsg); + notificationsProducer.send(topic, sessionId.toString(), transportMsg, new QueueCallbackAdaptor(onSuccess, onFailure)); + } + + private void forwardToDeviceActor(TransportToDeviceActorMsg toDeviceActorMsg) { + TransportToDeviceActorMsgWrapper wrapper = new TransportToDeviceActorMsgWrapper(toDeviceActorMsg); + Optional address = routingService.resolveById(wrapper.getDeviceId()); + if (address.isPresent()) { + log.trace("[{}] Pushing message to remote server: {}", address.get(), toDeviceActorMsg); + rpcService.tell(encodingService.convertToProtoDataMessage(address.get(), wrapper)); + } else { + log.trace("Pushing message to local server: {}", toDeviceActorMsg); + actorContext.getAppActor().tell(wrapper, ActorRef.noSender()); + } + } + + @PreDestroy + public void destroy() { + stopped = true; + if (ruleEngineConsumer != null) { + ruleEngineConsumer.unsubscribe(); + } + if (mainConsumerExecutor != null) { + mainConsumerExecutor.shutdownNow(); + } + } + + private static class QueueCallbackAdaptor implements Callback { + private final Runnable onSuccess; + private final Consumer onFailure; + + QueueCallbackAdaptor(Runnable onSuccess, Consumer onFailure) { + this.onSuccess = onSuccess; + this.onFailure = onFailure; + } + + @Override + public void onCompletion(RecordMetadata metadata, Exception exception) { + if (exception == null) { + if (onSuccess != null) { + onSuccess.run(); + } + } else { + if (onFailure != null) { + onFailure.accept(exception); + } + } + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java new file mode 100644 index 0000000000..6d422f4dd6 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/RemoteTransportApiService.java @@ -0,0 +1,109 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; +import org.thingsboard.server.kafka.*; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Created by ashvayka on 05.10.18. + */ +@Slf4j +@Component +@ConditionalOnProperty(prefix = "transport", value = "type", havingValue = "remote") +public class RemoteTransportApiService { + + @Value("${transport.remote.transport_api.requests_topic}") + private String transportApiRequestsTopic; + @Value("${transport.remote.transport_api.responses_topic}") + private String transportApiResponsesTopic; + @Value("${transport.remote.transport_api.max_pending_requests}") + private int maxPendingRequests; + @Value("${transport.remote.transport_api.request_timeout}") + private long requestTimeout; + @Value("${transport.remote.transport_api.request_poll_interval}") + private int responsePollDuration; + @Value("${transport.remote.transport_api.request_auto_commit_interval}") + private int autoCommitInterval; + + @Autowired + private TbKafkaSettings kafkaSettings; + + @Autowired + private TbNodeIdProvider nodeIdProvider; + + @Autowired + private TransportApiService transportApiService; + + private ExecutorService transportCallbackExecutor; + + private TbKafkaResponseTemplate transportApiTemplate; + + @PostConstruct + public void init() { + this.transportCallbackExecutor = new ThreadPoolExecutor(0, 100, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); + + TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder responseBuilder = TBKafkaProducerTemplate.builder(); + responseBuilder.settings(kafkaSettings); + responseBuilder.defaultTopic(transportApiResponsesTopic); + responseBuilder.encoder(new TransportApiResponseEncoder()); + + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder requestBuilder = TBKafkaConsumerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.topic(transportApiRequestsTopic); + requestBuilder.clientId(nodeIdProvider.getNodeId()); + requestBuilder.groupId("tb-node"); + requestBuilder.autoCommit(true); + requestBuilder.autoCommitIntervalMs(autoCommitInterval); + requestBuilder.decoder(new TransportApiRequestDecoder()); + + TbKafkaResponseTemplate.TbKafkaResponseTemplateBuilder + builder = TbKafkaResponseTemplate.builder(); + builder.requestTemplate(requestBuilder.build()); + builder.responseTemplate(responseBuilder.build()); + builder.maxPendingRequests(maxPendingRequests); + builder.requestTimeout(requestTimeout); + builder.pollInterval(responsePollDuration); + builder.executor(transportCallbackExecutor); + builder.handler(transportApiService); + transportApiTemplate = builder.build(); + transportApiTemplate.init(); + } + + @PreDestroy + public void destroy() { + if (transportApiTemplate != null) { + transportApiTemplate.stop(); + } + if (transportCallbackExecutor != null) { + transportCallbackExecutor.shutdownNow(); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java b/application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java new file mode 100644 index 0000000000..8ca484ffcf --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/RuleEngineTransportService.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.gen.transport.TransportProtos.DeviceActorToTransportMsg; + +import java.util.function.Consumer; + +/** + * Created by ashvayka on 05.10.18. + */ +public interface RuleEngineTransportService { + + void process(String nodeId, DeviceActorToTransportMsg msg); + + void process(String nodeId, DeviceActorToTransportMsg msg, Runnable onSuccess, Consumer onFailure); + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcSubscribeMsg.java b/application/src/main/java/org/thingsboard/server/service/transport/ToRuleEngineMsgDecoder.java similarity index 59% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcSubscribeMsg.java rename to application/src/main/java/org/thingsboard/server/service/transport/ToRuleEngineMsgDecoder.java index f8f24e8d20..16954eea10 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcSubscribeMsg.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/ToRuleEngineMsgDecoder.java @@ -13,18 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; +package org.thingsboard.server.service.transport; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.kafka.TbKafkaDecoder; + +import java.io.IOException; /** - * @author Andrew Shvayka + * Created by ashvayka on 05.10.18. */ -public class RpcSubscribeMsg implements FromDeviceMsg { +public class ToRuleEngineMsgDecoder implements TbKafkaDecoder { @Override - public SessionMsgType getMsgType() { - return SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST; + public ToRuleEngineMsg decode(byte[] data) throws IOException { + return ToRuleEngineMsg.parseFrom(data); } } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesSubscribeMsg.java b/application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java similarity index 63% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesSubscribeMsg.java rename to application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java index cadaf3ce56..bb0f5b620e 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesSubscribeMsg.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/ToTransportMsgEncoder.java @@ -13,17 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; +package org.thingsboard.server.service.transport; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.kafka.TbKafkaEncoder; /** - * @author Andrew Shvayka + * Created by ashvayka on 05.10.18. */ -public class AttributesSubscribeMsg implements FromDeviceMsg { +public class ToTransportMsgEncoder implements TbKafkaEncoder { @Override - public SessionMsgType getMsgType() { - return SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST; + public byte[] encode(ToTransportMsg value) { + return value.toByteArray(); } } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TransportApiRequestDecoder.java b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiRequestDecoder.java new file mode 100644 index 0000000000..c54f1a0303 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiRequestDecoder.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.kafka.TbKafkaDecoder; + +import java.io.IOException; + +/** + * Created by ashvayka on 05.10.18. + */ +public class TransportApiRequestDecoder implements TbKafkaDecoder { + @Override + public TransportApiRequestMsg decode(byte[] data) throws IOException { + return TransportApiRequestMsg.parseFrom(data); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TransportApiResponseEncoder.java b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiResponseEncoder.java new file mode 100644 index 0000000000..175a92df96 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiResponseEncoder.java @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.kafka.TbKafkaEncoder; + +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; + +/** + * Created by ashvayka on 05.10.18. + */ +public class TransportApiResponseEncoder implements TbKafkaEncoder { + @Override + public byte[] encode(TransportApiResponseMsg value) { + return value.toByteArray(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java new file mode 100644 index 0000000000..cc6f5b9f15 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/TransportApiService.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport; + +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.kafka.TbKafkaHandler; + +/** + * Created by ashvayka on 05.10.18. + */ +public interface TransportApiService extends TbKafkaHandler { +} diff --git a/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java b/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java new file mode 100644 index 0000000000..570082d542 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/transport/msg/TransportToDeviceActorMsgWrapper.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.transport.msg; + +import lombok.Data; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.MsgType; +import org.thingsboard.server.common.msg.TbActorMsg; +import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; +import org.thingsboard.server.common.msg.aware.TenantAwareMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; + +import java.io.Serializable; +import java.util.UUID; + +/** + * Created by ashvayka on 09.10.18. + */ +@Data +public class TransportToDeviceActorMsgWrapper implements TbActorMsg, DeviceAwareMsg, TenantAwareMsg, Serializable { + + private final TenantId tenantId; + private final DeviceId deviceId; + private final TransportToDeviceActorMsg msg; + + public TransportToDeviceActorMsgWrapper(TransportToDeviceActorMsg msg) { + this.msg = msg; + this.tenantId = new TenantId(new UUID(msg.getSessionInfo().getTenantIdMSB(), msg.getSessionInfo().getTenantIdLSB())); + this.deviceId = new DeviceId(new UUID(msg.getSessionInfo().getDeviceIdMSB(), msg.getSessionInfo().getDeviceIdLSB())); + } + + @Override + public MsgType getMsgType() { + return MsgType.TRANSPORT_TO_DEVICE_ACTOR_MSG; + } +} diff --git a/application/src/main/proto/cluster.proto b/application/src/main/proto/cluster.proto index 21c963b15e..1940b36c12 100644 --- a/application/src/main/proto/cluster.proto +++ b/application/src/main/proto/cluster.proto @@ -22,6 +22,7 @@ option java_outer_classname = "ClusterAPIProtos"; service ClusterRpcService { rpc handleMsgs(stream ClusterMessage) returns (stream ClusterMessage) {} } + message ClusterMessage { MessageType messageType = 1; MessageMataInfo messageMetaInfo = 2; @@ -139,4 +140,4 @@ message DeviceStateServiceMsgProto { bool added = 5; bool updated = 6; bool deleted = 7; -} \ No newline at end of file +} diff --git a/application/src/main/proto/jsinvoke.proto b/application/src/main/proto/jsinvoke.proto new file mode 100644 index 0000000000..a2afca2ab6 --- /dev/null +++ b/application/src/main/proto/jsinvoke.proto @@ -0,0 +1,87 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +syntax = "proto3"; +package js; + +option java_package = "org.thingsboard.server.gen.js"; +option java_outer_classname = "JsInvokeProtos"; + +enum JsInvokeErrorCode { + COMPILATION_ERROR = 0; + RUNTIME_ERROR = 1; + TIMEOUT_ERROR = 2; +} + +message RemoteJsRequest { + string responseTopic = 1; + int64 requestIdMSB = 2; + int64 requestIdLSB = 3; + JsCompileRequest compileRequest = 4; + JsInvokeRequest invokeRequest = 5; + JsReleaseRequest releaseRequest = 6; +} + +message RemoteJsResponse { + int64 requestIdMSB = 1; + int64 requestIdLSB = 2; + JsCompileResponse compileResponse = 3; + JsInvokeResponse invokeResponse = 4; + JsReleaseResponse releaseResponse = 5; +} + +message JsCompileRequest { + int64 scriptIdMSB = 1; + int64 scriptIdLSB = 2; + string functionName = 3; + string scriptBody = 4; +} + +message JsReleaseRequest { + int64 scriptIdMSB = 1; + int64 scriptIdLSB = 2; + string functionName = 3; +} + +message JsReleaseResponse { + bool success = 1; + int64 scriptIdMSB = 2; + int64 scriptIdLSB = 3; +} + +message JsCompileResponse { + bool success = 1; + int64 scriptIdMSB = 2; + int64 scriptIdLSB = 3; + JsInvokeErrorCode errorCode = 4; + string errorDetails = 5; +} + +message JsInvokeRequest { + int64 scriptIdMSB = 1; + int64 scriptIdLSB = 2; + string functionName = 3; + string scriptBody = 4; + int32 timeout = 5; + repeated string args = 6; +} + +message JsInvokeResponse { + bool success = 1; + string result = 2; + JsInvokeErrorCode errorCode = 3; + string errorDetails = 4; +} + diff --git a/application/src/main/resources/actor-system.conf b/application/src/main/resources/actor-system.conf index 3c687759e2..28673f6377 100644 --- a/application/src/main/resources/actor-system.conf +++ b/application/src/main/resources/actor-system.conf @@ -19,7 +19,7 @@ akka { # JVM shutdown, System.exit(-1), in case of a fatal error, # such as OutOfMemoryError jvm-exit-on-fatal-error = off - loglevel = "DEBUG" + loglevel = "INFO" loggers = ["akka.event.slf4j.Slf4jLogger"] } @@ -92,7 +92,7 @@ core-dispatcher { throughput = 5 } -# This dispatcher is used for system rule actors +# This dispatcher is used for system rule chains and rule node actors system-rule-dispatcher { type = Dispatcher executor = "fork-join-executor" @@ -115,30 +115,7 @@ system-rule-dispatcher { throughput = 5 } -# This dispatcher is used for system plugin actors -system-plugin-dispatcher { - type = Dispatcher - executor = "fork-join-executor" - fork-join-executor { - # Min number of threads to cap factor-based parallelism number to - parallelism-min = 2 - # Max number of threads to cap factor-based parallelism number to - parallelism-max = 12 - - # The parallelism factor is used to determine thread pool size using the - # following formula: ceil(available processors * factor). Resulting size - # is then bounded by the parallelism-min and parallelism-max values. - parallelism-factor = 0.25 - } - # How long time the dispatcher will wait for new actors until it shuts down - shutdown-timeout = 1s - - # Throughput defines the number of messages that are processed in a batch - # before the thread is returned to the pool. Set to 1 for as fair as possible. - throughput = 5 -} - -# This dispatcher is used for tenant rule actors +# This dispatcher is used for tenant rule chains and rule node actors rule-dispatcher { type = Dispatcher executor = "fork-join-executor" @@ -160,50 +137,3 @@ rule-dispatcher { # before the thread is returned to the pool. Set to 1 for as fair as possible. throughput = 5 } - -# This dispatcher is used for tenant plugin actors -plugin-dispatcher { - type = Dispatcher - executor = "fork-join-executor" - fork-join-executor { - # Min number of threads to cap factor-based parallelism number to - parallelism-min = 2 - # Max number of threads to cap factor-based parallelism number to - parallelism-max = 12 - - # The parallelism factor is used to determine thread pool size using the - # following formula: ceil(available processors * factor). Resulting size - # is then bounded by the parallelism-min and parallelism-max values. - parallelism-factor = 0.25 - } - # How long time the dispatcher will wait for new actors until it shuts down - shutdown-timeout = 1s - - # Throughput defines the number of messages that are processed in a batch - # before the thread is returned to the pool. Set to 1 for as fair as possible. - throughput = 5 -} - - -# This dispatcher is used for rule actors -session-dispatcher { - type = Dispatcher - executor = "fork-join-executor" - fork-join-executor { - # Min number of threads to cap factor-based parallelism number to - parallelism-min = 2 - # Max number of threads to cap factor-based parallelism number to - parallelism-max = 12 - - # The parallelism factor is used to determine thread pool size using the - # following formula: ceil(available processors * factor). Resulting size - # is then bounded by the parallelism-min and parallelism-max values. - parallelism-factor = 0.25 - } - # How long time the dispatcher will wait for new actors until it shuts down - shutdown-timeout = 1s - - # Throughput defines the number of messages that are processed in a batch - # before the thread is returned to the pool. Set to 1 for as fair as possible. - throughput = 5 -} \ No newline at end of file diff --git a/application/src/main/resources/logback.xml b/application/src/main/resources/logback.xml index 978a570e66..dcfc9301b2 100644 --- a/application/src/main/resources/logback.xml +++ b/application/src/main/resources/logback.xml @@ -17,7 +17,7 @@ --> - + diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 743a8607b5..60ce107a27 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -31,6 +31,7 @@ server: key-store-type: "${SSL_KEY_STORE_TYPE:PKCS12}" # Alias that identifies the key in the key store key-alias: "${SSL_KEY_ALIAS:tomcat}" + log_controller_error_stack_trace: "${HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE:true}" # Zookeeper connection parameters. Used for service discovery. zk: @@ -77,89 +78,18 @@ security: # Enable/disable access to Tenant Administrators JWT token by System Administrator or Customer Users JWT token by Tenant Administrator user_token_access_enabled: "${SECURITY_USER_TOKEN_ACCESS_ENABLED:true}" -# Device communication protocol parameters -http: - request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" - -# MQTT server parameters -mqtt: - # Enable/disable mqtt transport protocol. - enabled: "${MQTT_ENABLED:true}" - bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" - bind_port: "${MQTT_BIND_PORT:1883}" - adaptor: "${MQTT_ADAPTOR_NAME:JsonMqttAdaptor}" - timeout: "${MQTT_TIMEOUT:10000}" - netty: - leak_detector_level: "${NETTY_LEASK_DETECTOR_LVL:DISABLED}" - boss_group_thread_count: "${NETTY_BOSS_GROUP_THREADS:1}" - worker_group_thread_count: "${NETTY_WORKER_GROUP_THREADS:12}" - max_payload_size: "${NETTY_MAX_PAYLOAD_SIZE:65536}" - # MQTT SSL configuration - ssl: - # Enable/disable SSL support - enabled: "${MQTT_SSL_ENABLED:false}" - # SSL protocol: See http://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#SSLContext - protocol: "${MQTT_SSL_PROTOCOL:TLSv1.2}" - # Path to the key store that holds the SSL certificate - key_store: "${MQTT_SSL_KEY_STORE:mqttserver.jks}" - # Password used to access the key store - key_store_password: "${MQTT_SSL_KEY_STORE_PASSWORD:server_ks_password}" - # Password used to access the key - key_password: "${MQTT_SSL_KEY_PASSWORD:server_key_password}" - # Type of the key store - key_store_type: "${MQTT_SSL_KEY_STORE_TYPE:JKS}" - -# CoAP server parameters -coap: - # Enable/disable coap transport protocol. - enabled: "${COAP_ENABLED:false}" - bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" - bind_port: "${COAP_BIND_PORT:5683}" - adaptor: "${COAP_ADAPTOR_NAME:JsonCoapAdaptor}" - timeout: "${COAP_TIMEOUT:10000}" - -#Quota parameters -quota: - host: - # Max allowed number of API requests in interval for single host - limit: "${QUOTA_HOST_LIMIT:10000}" - # Interval duration - intervalMs: "${QUOTA_HOST_INTERVAL_MS:60000}" - # Maximum silence duration for host after which Host removed from QuotaService. Must be bigger than intervalMs - ttlMs: "${QUOTA_HOST_TTL_MS:60000}" - # Interval for scheduled task that cleans expired records. TTL is used for expiring - cleanPeriodMs: "${QUOTA_HOST_CLEAN_PERIOD_MS:300000}" - # Enable Host API Limits - enabled: "${QUOTA_HOST_ENABLED:false}" - # Array of whitelist hosts - whitelist: "${QUOTA_HOST_WHITELIST:localhost,127.0.0.1}" - # Array of blacklist hosts - blacklist: "${QUOTA_HOST_BLACKLIST:}" - log: - topSize: 10 - intervalMin: 2 - rule: - tenant: - # Max allowed number of API requests in interval for single tenant - limit: "${QUOTA_TENANT_LIMIT:100000}" - # Interval duration - intervalMs: "${QUOTA_TENANT_INTERVAL_MS:60000}" - # Maximum silence duration for tenant after which Tenant removed from QuotaService. Must be bigger than intervalMs - ttlMs: "${QUOTA_TENANT_TTL_MS:60000}" - # Interval for scheduled task that cleans expired records. TTL is used for expiring - cleanPeriodMs: "${QUOTA_TENANT_CLEAN_PERIOD_MS:300000}" - # Enable Host API Limits - enabled: "${QUOTA_TENANT_ENABLED:false}" - # Array of whitelist tenants - whitelist: "${QUOTA_TENANT_WHITELIST:}" - # Array of blacklist tenants - blacklist: "${QUOTA_HOST_BLACKLIST:}" - log: - topSize: 10 - intervalMin: 2 +# Dashboard parameters +dashboard: + # Maximum allowed datapoints fetched by widgets + max_datapoints_limit: "${DASHBOARD_MAX_DATAPOINTS_LIMIT:50000}" database: - type: "${DATABASE_TYPE:sql}" # cassandra OR sql + ts_max_intervals: "${DATABASE_TS_MAX_INTERVALS:700}" # mas number of DB queries generated by single API call to fetch telemetry records + entities: + type: "${DATABASE_ENTITIES_TYPE:sql}" # cassandra OR sql + ts: + type: "${DATABASE_TS_TYPE:sql}" # cassandra OR sql (for hybrid mode, only this value should be cassandra) + # Cassandra driver configuration parameters cassandra: @@ -181,7 +111,8 @@ cassandra: init_timeout_ms: "${CASSANDRA_CLUSTER_INIT_TIMEOUT_MS:300000}" # Specify cassandra claster initialization retry interval (if no hosts available during startup) init_retry_interval_ms: "${CASSANDRA_CLUSTER_INIT_RETRY_INTERVAL_MS:3000}" - + max_requests_per_connection_local: "${CASSANDRA_MAX_REQUESTS_PER_CONNECTION_LOCAL:32768}" + max_requests_per_connection_remote: "${CASSANDRA_MAX_REQUESTS_PER_CONNECTION_REMOTE:32768}" # Credential parameters # credentials: "${CASSANDRA_USE_CREDENTIALS:false}" # Specify your username @@ -211,13 +142,7 @@ cassandra: buffer_size: "${CASSANDRA_QUERY_BUFFER_SIZE:200000}" concurrent_limit: "${CASSANDRA_QUERY_CONCURRENT_LIMIT:1000}" permit_max_wait_time: "${PERMIT_MAX_WAIT_TIME:120000}" - rate_limit_print_interval_ms: "${CASSANDRA_QUERY_RATE_LIMIT_PRINT_MS:30000}" - - queue: - msg.ttl: 604800 # 7 days - ack.ttl: 604800 # 7 days - partitions.ttl: 604800 # 7 days - partitioning: "HOURS" + rate_limit_print_interval_ms: "${CASSANDRA_QUERY_RATE_LIMIT_PRINT_MS:10000}" # SQL configuration parameters sql: @@ -246,28 +171,12 @@ actors: allow_system_mail_service: "${ACTORS_RULE_ALLOW_SYSTEM_MAIL_SERVICE:true}" # Specify thread pool size for external call service external_call_thread_pool_size: "${ACTORS_RULE_EXTERNAL_CALL_THREAD_POOL_SIZE:10}" - js_sandbox: - # Use Sandboxed (secured) JavaScript environment - use_js_sandbox: "${ACTORS_RULE_JS_SANDBOX_USE_JS_SANDBOX:true}" - # Specify thread pool size for JavaScript sandbox resource monitor - monitor_thread_pool_size: "${ACTORS_RULE_JS_SANDBOX_MONITOR_THREAD_POOL_SIZE:4}" - # Maximum CPU time in milliseconds allowed for script execution - max_cpu_time: "${ACTORS_RULE_JS_SANDBOX_MAX_CPU_TIME:100}" - # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted - max_errors: "${ACTORS_RULE_JS_SANDBOX_MAX_ERRORS:3}" chain: # Errors for particular actor are persisted once per specified amount of milliseconds error_persist_frequency: "${ACTORS_RULE_CHAIN_ERROR_FREQUENCY:3000}" node: # Errors for particular actor are persisted once per specified amount of milliseconds error_persist_frequency: "${ACTORS_RULE_NODE_ERROR_FREQUENCY:3000}" - queue: - # Message queue type - type: "${ACTORS_RULE_QUEUE_TYPE:memory}" - # Message queue maximum size (per tenant) - max_size: "${ACTORS_RULE_QUEUE_MAX_SIZE:100}" - # Message queue cleanup period in seconds - cleanup_period: "${ACTORS_RULE_QUEUE_CLEANUP_PERIOD:3600}" statistics: # Enable/disable actor statistics enabled: "${ACTORS_STATISTICS_ENABLED:true}" @@ -295,9 +204,15 @@ caffeine: devices: timeToLiveInMinutes: 1440 maxSize: 100000 + sessions: + timeToLiveInMinutes: 1440 + maxSize: 100000 assets: timeToLiveInMinutes: 1440 maxSize: 100000 + entityViews: + timeToLiveInMinutes: 1440 + maxSize: 100000 redis: # standalone or cluster @@ -312,7 +227,7 @@ redis: updates: # Enable/disable updates checking. enabled: "${UPDATES_ENABLED:true}" - + # spring CORS configuration spring.mvc.cors: mappings: @@ -359,7 +274,7 @@ spring: password: "${SPRING_DATASOURCE_PASSWORD:}" # PostgreSQL DAO Configuration -#spring: +# spring: # data: # sql: # repositories: @@ -393,6 +308,7 @@ audit_log: "user": "${AUDIT_LOG_MASK_USER:W}" "rule_chain": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" "alarm": "${AUDIT_LOG_MASK_ALARM:W}" + "entity_view": "${AUDIT_LOG_MASK_RULE_CHAIN:W}" sink: # Type of external sink. possible options: none, elasticsearch type: "${AUDIT_LOG_SINK_TYPE:none}" @@ -411,8 +327,123 @@ audit_log: password: "${AUDIT_LOG_SINK_PASSWORD:}" state: - defaultInactivityTimeoutInSec: 10 - defaultStateCheckIntervalInSec: 10 -# TODO in v2.1 -# defaultStatePersistenceIntervalInSec: 60 -# defaultStatePersistencePack: 100 \ No newline at end of file + defaultInactivityTimeoutInSec: "${DEFAULT_INACTIVITY_TIMEOUT:10}" + defaultStateCheckIntervalInSec: "${DEFAULT_STATE_CHECK_INTERVAL:10}" + +kafka: + enabled: true + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + acks: "${TB_KAFKA_ACKS:all}" + retries: "${TB_KAFKA_RETRIES:1}" + batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" + linger.ms: "${TB_KAFKA_LINGER_MS:1}" + buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + transport_api: + requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + request_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + request_poll_interval: "${TB_TRANSPORT_REQUEST_POLL_INTERVAL_MS:25}" + request_auto_commit_interval: "${TB_TRANSPORT_REQUEST_AUTO_COMMIT_INTERVAL_MS:100}" + rule_engine: + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" + poll_interval: "${TB_RULE_ENGINE_POLL_INTERVAL_MS:25}" + auto_commit_interval: "${TB_RULE_ENGINE_AUTO_COMMIT_INTERVAL_MS:100}" + notifications: + topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" + +js: + evaluator: "${JS_EVALUATOR:local}" # local/remote + # Built-in JVM JavaScript environment properties + local: + # Use Sandboxed (secured) JVM JavaScript environment + use_js_sandbox: "${USE_LOCAL_JS_SANDBOX:true}" + # Specify thread pool size for JavaScript sandbox resource monitor + monitor_thread_pool_size: "${LOCAL_JS_SANDBOX_MONITOR_THREAD_POOL_SIZE:4}" + # Maximum CPU time in milliseconds allowed for script execution + max_cpu_time: "${LOCAL_JS_SANDBOX_MAX_CPU_TIME:100}" + # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted + max_errors: "${LOCAL_JS_SANDBOX_MAX_ERRORS:3}" + # Remote JavaScript environment properties + remote: + # JS Eval request topic + request_topic: "${REMOTE_JS_EVAL_REQUEST_TOPIC:js.eval.requests}" + # JS Eval responses topic prefix that is combined with node id + response_topic_prefix: "${REMOTE_JS_EVAL_REQUEST_TOPIC:js.eval.responses}" + # JS Eval max pending requests + max_pending_requests: "${REMOTE_JS_MAX_PENDING_REQUESTS:10000}" + # JS Eval max request timeout + max_requests_timeout: "${REMOTE_JS_MAX_REQUEST_TIMEOUT:10000}" + # JS response poll interval + response_poll_interval: "${REMOTE_JS_RESPONSE_POLL_INTERVAL_MS:25}" + # JS response auto commit interval + response_auto_commit_interval: "${REMOTE_JS_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" + # Maximum allowed JavaScript execution errors before JavaScript will be blacklisted + max_errors: "${REMOTE_JS_SANDBOX_MAX_ERRORS:3}" + +transport: + type: "${TRANSPORT_TYPE:local}" # local or remote + remote: + transport_api: + requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + request_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + request_poll_interval: "${TB_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" + request_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:1000}" + rule_engine: + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" + poll_interval: "${TB_RULE_ENGINE_POLL_INTERVAL_MS:25}" + auto_commit_interval: "${TB_RULE_ENGINE_AUTO_COMMIT_INTERVAL_MS:100}" + poll_records_pack_size: "${TB_RULE_ENGINE_MAX_POLL_RECORDS:1000}" + max_poll_records_per_second: "${TB_RULE_ENGINE_MAX_POLL_RECORDS_PER_SECOND:10000}" + max_poll_records_per_minute: "${TB_RULE_ENGINE_MAX_POLL_RECORDS_PER_MINUTE:120000}" + notifications: + topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" + sessions: + inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" + report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:30000}" + rate_limits: + enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" + tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" + device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" + json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" + # Local HTTP transport parameters + http: + enabled: "${HTTP_ENABLED:true}" + request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" + # Local MQTT transport parameters + mqtt: + # Enable/disable mqtt transport protocol. + enabled: "${MQTT_ENABLED:true}" + bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" + bind_port: "${MQTT_BIND_PORT:1883}" + timeout: "${MQTT_TIMEOUT:10000}" + netty: + leak_detector_level: "${NETTY_LEAK_DETECTOR_LVL:DISABLED}" + boss_group_thread_count: "${NETTY_BOSS_GROUP_THREADS:1}" + worker_group_thread_count: "${NETTY_WORKER_GROUP_THREADS:12}" + max_payload_size: "${NETTY_MAX_PAYLOAD_SIZE:65536}" + # MQTT SSL configuration + ssl: + # Enable/disable SSL support + enabled: "${MQTT_SSL_ENABLED:false}" + # SSL protocol: See http://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#SSLContext + protocol: "${MQTT_SSL_PROTOCOL:TLSv1.2}" + # Path to the key store that holds the SSL certificate + key_store: "${MQTT_SSL_KEY_STORE:mqttserver.jks}" + # Password used to access the key store + key_store_password: "${MQTT_SSL_KEY_STORE_PASSWORD:server_ks_password}" + # Password used to access the key + key_password: "${MQTT_SSL_KEY_PASSWORD:server_key_password}" + # Type of the key store + key_store_type: "${MQTT_SSL_KEY_STORE_TYPE:JKS}" + # Local CoAP transport parameters + coap: + # Enable/disable coap transport protocol. + enabled: "${COAP_ENABLED:true}" + bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" + bind_port: "${COAP_BIND_PORT:5683}" + timeout: "${COAP_TIMEOUT:10000}" diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java index 1b042a821c..cb31a4b50c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractRuleEngineControllerTest.java @@ -27,9 +27,7 @@ import org.thingsboard.server.common.data.page.TimePageData; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; -import org.thingsboard.server.dao.queue.MsgQueue; import org.thingsboard.server.dao.rule.RuleChainService; -import org.thingsboard.server.service.queue.MsgQueueService; import java.io.IOException; import java.util.function.Predicate; @@ -42,9 +40,6 @@ public class AbstractRuleEngineControllerTest extends AbstractControllerTest { @Autowired protected RuleChainService ruleChainService; - @Autowired - protected MsgQueueService msgQueueService; - protected RuleChain saveRuleChain(RuleChain ruleChain) throws Exception { return doPost("/api/ruleChain", ruleChain, RuleChain.class); } diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java new file mode 100644 index 0000000000..31869f798b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEntityViewControllerTest.java @@ -0,0 +1,533 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.datastax.driver.core.utils.UUIDs; +import com.fasterxml.jackson.core.type.TypeReference; +import org.apache.commons.lang3.RandomStringUtils; +import org.eclipse.paho.client.mqttv3.MqttAsyncClient; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.objects.AttributesEntityView; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; +import org.thingsboard.server.common.data.page.TextPageData; +import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.dao.model.ModelConstants; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.hamcrest.Matchers.containsString; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; + +public abstract class BaseEntityViewControllerTest extends AbstractControllerTest { + + private IdComparator idComparator; + private Tenant savedTenant; + private User tenantAdmin; + private Device testDevice; + private TelemetryEntityView telemetry; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + idComparator = new IdComparator<>(); + + savedTenant = doPost("/api/tenant", getNewTenant("My tenant"), Tenant.class); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + + Device device = new Device(); + device.setName("Test device"); + device.setType("default"); + testDevice = doPost("/api/device", device, Device.class); + + telemetry = new TelemetryEntityView( + Arrays.asList("tsKey1", "tsKey2", "tsKey3"), + new AttributesEntityView( + Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4"), + Arrays.asList("saKey1", "saKey2", "saKey3", "saKey4"), + Arrays.asList("shKey1", "shKey2", "shKey3", "shKey4"))); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testFindEntityViewById() throws Exception { + EntityView savedView = getNewSavedEntityView("Test entity view"); + EntityView foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); + Assert.assertNotNull(foundView); + assertEquals(savedView, foundView); + } + + @Test + public void testSaveEntityView() throws Exception { + EntityView savedView = getNewSavedEntityView("Test entity view"); + + Assert.assertNotNull(savedView); + Assert.assertNotNull(savedView.getId()); + Assert.assertTrue(savedView.getCreatedTime() > 0); + assertEquals(savedTenant.getId(), savedView.getTenantId()); + Assert.assertNotNull(savedView.getCustomerId()); + assertEquals(NULL_UUID, savedView.getCustomerId().getId()); + assertEquals(savedView.getName(), savedView.getName()); + + savedView.setName("New test entity view"); + doPost("/api/entityView", savedView, EntityView.class); + EntityView foundEntityView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); + + assertEquals(foundEntityView.getName(), savedView.getName()); + assertEquals(foundEntityView.getKeys(), telemetry); + } + + @Test + public void testDeleteEntityView() throws Exception { + EntityView view = getNewSavedEntityView("Test entity view"); + Customer customer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); + view.setCustomerId(customer.getId()); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + doDelete("/api/entityView/" + savedView.getId().getId().toString()) + .andExpect(status().isOk()); + + doGet("/api/entityView/" + savedView.getId().getId().toString()) + .andExpect(status().isNotFound()); + } + + @Test + public void testSaveEntityViewWithEmptyName() throws Exception { + EntityView entityView = new EntityView(); + entityView.setType("default"); + doPost("/api/entityView", entityView) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Entity view name should be specified!"))); + } + + @Test + public void testAssignAndUnAssignedEntityViewToCustomer() throws Exception { + EntityView view = getNewSavedEntityView("Test entity view"); + Customer savedCustomer = doPost("/api/customer", getNewCustomer("My customer"), Customer.class); + view.setCustomerId(savedCustomer.getId()); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + EntityView assignedView = doPost( + "/api/customer/" + savedCustomer.getId().getId().toString() + "/entityView/" + savedView.getId().getId().toString(), + EntityView.class); + assertEquals(savedCustomer.getId(), assignedView.getCustomerId()); + + EntityView foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); + assertEquals(savedCustomer.getId(), foundView.getCustomerId()); + + EntityView unAssignedView = doDelete("/api/customer/entityView/" + savedView.getId().getId().toString(), EntityView.class); + assertEquals(ModelConstants.NULL_UUID, unAssignedView.getCustomerId().getId()); + + foundView = doGet("/api/entityView/" + savedView.getId().getId().toString(), EntityView.class); + assertEquals(ModelConstants.NULL_UUID, foundView.getCustomerId().getId()); + } + + @Test + public void testAssignEntityViewToNonExistentCustomer() throws Exception { + EntityView savedView = getNewSavedEntityView("Test entity view"); + doPost("/api/customer/" + UUIDs.timeBased().toString() + "/device/" + savedView.getId().getId().toString()) + .andExpect(status().isNotFound()); + } + + @Test + public void testAssignEntityViewToCustomerFromDifferentTenant() throws Exception { + loginSysAdmin(); + + Tenant tenant2 = getNewTenant("Different tenant"); + Tenant savedTenant2 = doPost("/api/tenant", tenant2, Tenant.class); + Assert.assertNotNull(savedTenant2); + + User tenantAdmin2 = new User(); + tenantAdmin2.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin2.setTenantId(savedTenant2.getId()); + tenantAdmin2.setEmail("tenant3@thingsboard.org"); + tenantAdmin2.setFirstName("Joe"); + tenantAdmin2.setLastName("Downs"); + createUserAndLogin(tenantAdmin2, "testPassword1"); + + Customer customer = getNewCustomer("Different customer"); + Customer savedCustomer = doPost("/api/customer", customer, Customer.class); + + login(tenantAdmin.getEmail(), "testPassword1"); + + EntityView savedView = getNewSavedEntityView("Test entity view"); + + doPost("/api/customer/" + savedCustomer.getId().getId().toString() + "/entityView/" + savedView.getId().getId().toString()) + .andExpect(status().isForbidden()); + + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant2.getId().getId().toString()) + .andExpect(status().isOk()); + } + + @Test + public void testGetCustomerEntityViews() throws Exception { + CustomerId customerId = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class).getId(); + String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entityViews?"; + + List views = new ArrayList<>(); + for (int i = 0; i < 128; i++) { + views.add(doPost("/api/customer/" + customerId.getId().toString() + "/entityView/" + + getNewSavedEntityView("Test entity view " + i).getId().getId().toString(), EntityView.class)); + } + + List loadedViews = loadListOf(new TextPageLink(23), urlTemplate); + + Collections.sort(views, idComparator); + Collections.sort(loadedViews, idComparator); + + assertEquals(views, loadedViews); + } + + @Test + public void testGetCustomerEntityViewsByName() throws Exception { + CustomerId customerId = doPost("/api/customer", getNewCustomer("Test customer"), Customer.class).getId(); + String urlTemplate = "/api/customer/" + customerId.getId().toString() + "/entityViews?"; + + String name1 = "Entity view name1"; + List namesOfView1 = fillListOf(125, name1, "/api/customer/" + customerId.getId().toString() + + "/entityView/"); + List loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), urlTemplate); + Collections.sort(namesOfView1, idComparator); + Collections.sort(loadedNamesOfView1, idComparator); + assertEquals(namesOfView1, loadedNamesOfView1); + + String name2 = "Entity view name2"; + List NamesOfView2 = fillListOf(143, name2, "/api/customer/" + customerId.getId().toString() + + "/entityView/"); + List loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), urlTemplate); + Collections.sort(NamesOfView2, idComparator); + Collections.sort(loadedNamesOfView2, idComparator); + assertEquals(NamesOfView2, loadedNamesOfView2); + + for (EntityView view : loadedNamesOfView1) { + doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); + } + TextPageData pageData = doGetTypedWithPageLink(urlTemplate, + new TypeReference>() { + }, new TextPageLink(4, name1)); + Assert.assertFalse(pageData.hasNext()); + assertEquals(0, pageData.getData().size()); + + for (EntityView view : loadedNamesOfView2) { + doDelete("/api/customer/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); + } + pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference>() { + }, + new TextPageLink(4, name2)); + Assert.assertFalse(pageData.hasNext()); + assertEquals(0, pageData.getData().size()); + } + + @Test + public void testGetTenantEntityViews() throws Exception { + + List views = new ArrayList<>(); + for (int i = 0; i < 178; i++) { + views.add(getNewSavedEntityView("Test entity view" + i)); + } + List loadedViews = loadListOf(new TextPageLink(23), "/api/tenant/entityViews?"); + + Collections.sort(views, idComparator); + Collections.sort(loadedViews, idComparator); + + assertEquals(views, loadedViews); + } + + @Test + public void testGetTenantEntityViewsByName() throws Exception { + String name1 = "Entity view name1"; + List namesOfView1 = fillListOf(143, name1); + List loadedNamesOfView1 = loadListOf(new TextPageLink(15, name1), "/api/tenant/entityViews?"); + Collections.sort(namesOfView1, idComparator); + Collections.sort(loadedNamesOfView1, idComparator); + assertEquals(namesOfView1, loadedNamesOfView1); + + String name2 = "Entity view name2"; + List NamesOfView2 = fillListOf(75, name2); + List loadedNamesOfView2 = loadListOf(new TextPageLink(4, name2), "/api/tenant/entityViews?"); + Collections.sort(NamesOfView2, idComparator); + Collections.sort(loadedNamesOfView2, idComparator); + assertEquals(NamesOfView2, loadedNamesOfView2); + + for (EntityView view : loadedNamesOfView1) { + doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); + } + TextPageData pageData = doGetTypedWithPageLink("/api/tenant/entityViews?", + new TypeReference>() { + }, new TextPageLink(4, name1)); + Assert.assertFalse(pageData.hasNext()); + assertEquals(0, pageData.getData().size()); + + for (EntityView view : loadedNamesOfView2) { + doDelete("/api/entityView/" + view.getId().getId().toString()).andExpect(status().isOk()); + } + pageData = doGetTypedWithPageLink("/api/tenant/entityViews?", new TypeReference>() { + }, + new TextPageLink(4, name2)); + Assert.assertFalse(pageData.hasNext()); + assertEquals(0, pageData.getData().size()); + } + + @Test + public void testTheCopyOfAttrsIntoTSForTheView() throws Exception { + Set actualAttributesSet = + getAttributesByKeys("{\"caKey1\":\"value1\", \"caKey2\":true, \"caKey3\":42.0, \"caKey4\":73}"); + + Set expectedActualAttributesSet = + new HashSet<>(Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4")); + assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); + + EntityView savedView = getNewSavedEntityView("Test entity view"); + + Thread.sleep(1000); + + List> values = doGetAsync("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); + + assertEquals("value1", getValue(values, "caKey1")); + assertEquals(true, getValue(values, "caKey2")); + assertEquals(42.0, getValue(values, "caKey3")); + assertEquals(73, getValue(values, "caKey4")); + } + + @Test + public void testTheCopyOfAttrsOutOfTSForTheView() throws Exception { + Set actualAttributesSet = + getAttributesByKeys("{\"caKey1\":\"value1\", \"caKey2\":true, \"caKey3\":42.0, \"caKey4\":73}"); + + Set expectedActualAttributesSet = new HashSet<>(Arrays.asList("caKey1", "caKey2", "caKey3", "caKey4")); + assertTrue(actualAttributesSet.containsAll(expectedActualAttributesSet)); + + List> valueTelemetryOfDevices = doGetAsync("/api/plugins/telemetry/DEVICE/" + testDevice.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); + + EntityView view = new EntityView(); + view.setEntityId(testDevice.getId()); + view.setTenantId(savedTenant.getId()); + view.setName("Test entity view"); + view.setType("default"); + view.setKeys(telemetry); + view.setStartTimeMs((long) getValue(valueTelemetryOfDevices, "lastActivityTime") * 10); + view.setEndTimeMs((long) getValue(valueTelemetryOfDevices, "lastActivityTime") / 10); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + + Thread.sleep(1000); + + List> values = doGetAsync("/api/plugins/telemetry/ENTITY_VIEW/" + savedView.getId().getId().toString() + + "/values/attributes?keys=" + String.join(",", actualAttributesSet), List.class); + assertEquals(0, values.size()); + } + + + @Test + public void testGetTelemetryWhenEntityViewTimeRangeInsideTimestampRange() throws Exception { + uploadTelemetry("{\"tsKey1\":\"value1\", \"tsKey2\":true, \"tsKey3\":40.0}"); + Thread.sleep(1000); + long startTimeMs = System.currentTimeMillis(); + uploadTelemetry("{\"tsKey1\":\"value2\", \"tsKey2\":false, \"tsKey3\":80.0}"); + Thread.sleep(1000); + uploadTelemetry("{\"tsKey1\":\"value3\", \"tsKey2\":false, \"tsKey3\":120.0}"); + long endTimeMs = System.currentTimeMillis(); + uploadTelemetry("{\"tsKey1\":\"value4\", \"tsKey2\":true, \"tsKey3\":160.0}"); + + String deviceId = testDevice.getId().getId().toString(); + Set keys = getTelemetryKeys("DEVICE", deviceId); + Thread.sleep(1000); + + EntityView view = createEntityView("Test entity view", startTimeMs, endTimeMs); + EntityView savedView = doPost("/api/entityView", view, EntityView.class); + String entityViewId = savedView.getId().getId().toString(); + + Map>> expectedValues = getTelemetryValues("DEVICE", deviceId, keys, 0L, (startTimeMs + endTimeMs) / 2); + Assert.assertEquals(2, expectedValues.get("tsKey1").size()); + Assert.assertEquals(2, expectedValues.get("tsKey2").size()); + Assert.assertEquals(2, expectedValues.get("tsKey3").size()); + + Map>> actualValues = getTelemetryValues("ENTITY_VIEW", entityViewId, keys, 0L, (startTimeMs + endTimeMs) / 2); + Assert.assertEquals(1, actualValues.get("tsKey1").size()); + Assert.assertEquals(1, actualValues.get("tsKey2").size()); + Assert.assertEquals(1, actualValues.get("tsKey3").size()); + } + + private void uploadTelemetry(String strKvs) throws Exception { + String viewDeviceId = testDevice.getId().getId().toString(); + DeviceCredentials deviceCredentials = + doGet("/api/device/" + viewDeviceId + "/credentials", DeviceCredentials.class); + assertEquals(testDevice.getId(), deviceCredentials.getDeviceId()); + + String accessToken = deviceCredentials.getCredentialsId(); + assertNotNull(accessToken); + + String clientId = MqttAsyncClient.generateClientId(); + MqttAsyncClient client = new MqttAsyncClient("tcp://localhost:1883", clientId); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(accessToken); + client.connect(options); + Thread.sleep(3000); + + MqttMessage message = new MqttMessage(); + message.setPayload(strKvs.getBytes()); + client.publish("v1/devices/me/telemetry", message); + Thread.sleep(1000); + } + + private Set getTelemetryKeys(String type, String id) throws Exception { + return new HashSet<>(doGetAsync("/api/plugins/telemetry/" + type + "/" + id + "/keys/timeseries", List.class)); + } + + private Map>> getTelemetryValues(String type, String id, Set keys, Long startTs, Long endTs) throws Exception { + return doGetAsync("/api/plugins/telemetry/" + type + "/" + id + + "/values/timeseries?keys=" + String.join(",", keys) + "&startTs=" + startTs + "&endTs=" + endTs, Map.class); + } + + private Set getAttributesByKeys(String stringKV) throws Exception { + String viewDeviceId = testDevice.getId().getId().toString(); + DeviceCredentials deviceCredentials = + doGet("/api/device/" + viewDeviceId + "/credentials", DeviceCredentials.class); + assertEquals(testDevice.getId(), deviceCredentials.getDeviceId()); + + String accessToken = deviceCredentials.getCredentialsId(); + assertNotNull(accessToken); + + String clientId = MqttAsyncClient.generateClientId(); + MqttAsyncClient client = new MqttAsyncClient("tcp://localhost:1883", clientId); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(accessToken); + client.connect(options); + Thread.sleep(3000); + + MqttMessage message = new MqttMessage(); + message.setPayload((stringKV).getBytes()); + client.publish("v1/devices/me/attributes", message); + Thread.sleep(1000); + + return new HashSet<>(doGetAsync("/api/plugins/telemetry/DEVICE/" + viewDeviceId + "/keys/attributes", List.class)); + } + + private Object getValue(List> values, String stringValue) { + return values.size() == 0 ? null : + values.stream() + .filter(value -> value.get("key").equals(stringValue)) + .findFirst().get().get("value"); + } + + private EntityView getNewSavedEntityView(String name) throws Exception { + EntityView view = createEntityView(name, 0, 0); + return doPost("/api/entityView", view, EntityView.class); + } + + private EntityView createEntityView(String name, long startTimeMs, long endTimeMs) { + EntityView view = new EntityView(); + view.setEntityId(testDevice.getId()); + view.setTenantId(savedTenant.getId()); + view.setName(name); + view.setType("default"); + view.setKeys(telemetry); + view.setStartTimeMs(startTimeMs); + view.setEndTimeMs(endTimeMs); + return view; + } + + private Customer getNewCustomer(String title) { + Customer customer = new Customer(); + customer.setTitle(title); + return customer; + } + + private Tenant getNewTenant(String title) { + Tenant tenant = new Tenant(); + tenant.setTitle(title); + return tenant; + } + + private List fillListOf(int limit, String partOfName, String urlTemplate) throws Exception { + List views = new ArrayList<>(); + for (EntityView view : fillListOf(limit, partOfName)) { + views.add(doPost(urlTemplate + view.getId().getId().toString(), EntityView.class)); + } + return views; + } + + private List fillListOf(int limit, String partOfName) throws Exception { + List viewNames = new ArrayList<>(); + for (int i = 0; i < limit; i++) { + String fullName = partOfName + ' ' + RandomStringUtils.randomAlphanumeric(15); + fullName = i % 2 == 0 ? fullName.toLowerCase() : fullName.toUpperCase(); + EntityView view = getNewSavedEntityView(fullName); + Customer customer = getNewCustomer("Test customer " + String.valueOf(Math.random())); + view.setCustomerId(doPost("/api/customer", customer, Customer.class).getId()); + viewNames.add(doPost("/api/entityView", view, EntityView.class)); + } + return viewNames; + } + + private List loadListOf(TextPageLink pageLink, String urlTemplate) throws Exception { + List loadedItems = new ArrayList<>(); + TextPageData pageData; + do { + pageData = doGetTypedWithPageLink(urlTemplate, new TypeReference>() { + }, pageLink); + loadedItems.addAll(pageData.getData()); + if (pageData.hasNext()) { + pageLink = pageData.getNextPageLink(); + } + } while (pageData.hasNext()); + + return loadedItems; + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/ControllerNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/controller/ControllerNoSqlTestSuite.java index 2e8848305f..f378437312 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ControllerNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/controller/ControllerNoSqlTestSuite.java @@ -32,7 +32,8 @@ public class ControllerNoSqlTestSuite { public static CustomCassandraCQLUnit cassandraUnit = new CustomCassandraCQLUnit( Arrays.asList( - new ClassPathCQLDataSet("cassandra/schema.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-ts.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-entities.cql", false, false), new ClassPathCQLDataSet("cassandra/system-data.cql", false, false), new ClassPathCQLDataSet("cassandra/system-test.cql", false, false)), "cassandra-test.yaml", 30000l); diff --git a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java index f316051212..cdfa00136c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/controller/ControllerSqlTestSuite.java @@ -24,13 +24,13 @@ import java.util.Arrays; @RunWith(ClasspathSuite.class) @ClasspathSuite.ClassnameFilters({ - "org.thingsboard.server.controller.sql.*SqlTest", + "org.thingsboard.server.controller.sql.*Test", }) public class ControllerSqlTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql"), "sql/drop-all-tables.sql", "sql-test.properties"); } diff --git a/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java b/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java new file mode 100644 index 0000000000..404e4e253f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/nosql/EntityViewControllerNoSqlTest.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller.nosql; + +import org.thingsboard.server.controller.BaseEntityViewControllerTest; +import org.thingsboard.server.dao.service.DaoNoSqlTest; + +/** + * Created by Victor Basanets on 8/27/2017. + */ +@DaoNoSqlTest +public class EntityViewControllerNoSqlTest extends BaseEntityViewControllerTest { +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ActorSystemToDeviceSessionActorMsg.java b/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java similarity index 59% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/ActorSystemToDeviceSessionActorMsg.java rename to application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java index 6bb5c9911d..76d99250c1 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ActorSystemToDeviceSessionActorMsg.java +++ b/application/src/test/java/org/thingsboard/server/controller/sql/EntityViewControllerSqlTest.java @@ -13,18 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; +package org.thingsboard.server.controller.sql; -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; +import org.junit.Assert; +import org.junit.Test; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.controller.BaseEntityViewControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; -import java.io.Serializable; +import java.util.Arrays; /** - * @author Andrew Shvayka + * Created by Victor Basanets on 8/27/2017. */ -public interface ActorSystemToDeviceSessionActorMsg extends SessionAwareMsg, Serializable, TbActorMsg { - - ToDeviceMsg getMsg(); +@DaoSqlTest +public class EntityViewControllerSqlTest extends BaseEntityViewControllerTest { } diff --git a/application/src/test/java/org/thingsboard/server/mqtt/MqttNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/mqtt/MqttNoSqlTestSuite.java index c4a969b1a9..2bb8a81ef2 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/MqttNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/MqttNoSqlTestSuite.java @@ -32,7 +32,8 @@ public class MqttNoSqlTestSuite { public static CustomCassandraCQLUnit cassandraUnit = new CustomCassandraCQLUnit( Arrays.asList( - new ClassPathCQLDataSet("cassandra/schema.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-ts.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-entities.cql", false, false), new ClassPathCQLDataSet("cassandra/system-data.cql", false, false)), "cassandra-test.yaml", 30000l); } diff --git a/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java index 5ddbb67c97..1389c7edde 100644 --- a/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/mqtt/MqttSqlTestSuite.java @@ -15,11 +15,9 @@ */ package org.thingsboard.server.mqtt; -import org.cassandraunit.dataset.cql.ClassPathCQLDataSet; import org.junit.ClassRule; import org.junit.extensions.cpsuite.ClasspathSuite; import org.junit.runner.RunWith; -import org.thingsboard.server.dao.CustomCassandraCQLUnit; import org.thingsboard.server.dao.CustomSqlUnit; import java.util.Arrays; @@ -31,7 +29,7 @@ public class MqttSqlTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql"), "sql/drop-all-tables.sql", "sql-test.properties"); } 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 f3e29dc1ad..7453682644 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 @@ -16,6 +16,7 @@ package org.thingsboard.server.mqtt.rpc; import com.datastax.driver.core.utils.UUIDs; +import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.eclipse.paho.client.mqttv3.IMqttDeliveryToken; @@ -23,19 +24,19 @@ import org.eclipse.paho.client.mqttv3.MqttAsyncClient; import org.eclipse.paho.client.mqttv3.MqttCallback; import org.eclipse.paho.client.mqttv3.MqttConnectOptions; import org.eclipse.paho.client.mqttv3.MqttMessage; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; +import org.junit.*; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.mqtt.telemetry.AbstractMqttTelemetryIntegrationTest; import org.thingsboard.server.service.security.AccessValidator; import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -101,13 +102,19 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC MqttConnectOptions options = new MqttConnectOptions(); options.setUserName(accessToken); client.connect(options).waitForCompletion(); - client.subscribe("v1/devices/me/rpc/request/+", 1); - client.setCallback(new TestMqttCallback(client)); + + CountDownLatch latch = new CountDownLatch(1); + TestMqttCallback callback = new TestMqttCallback(client, latch); + client.setCallback(callback); + + client.subscribe("v1/devices/me/rpc/request/+", MqttQoS.AT_MOST_ONCE.value()); String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}"; String deviceId = savedDevice.getId().getId().toString(); String result = doPostAsync("/api/plugins/rpc/oneway/" + deviceId, setGpioRequest, String.class, status().isOk()); Assert.assertTrue(StringUtils.isEmpty(result)); + latch.await(3, TimeUnit.SECONDS); + assertEquals(MqttQoS.AT_MOST_ONCE.value(), callback.getQoS()); } @Test @@ -156,7 +163,7 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC options.setUserName(accessToken); client.connect(options).waitForCompletion(); client.subscribe("v1/devices/me/rpc/request/+", 1); - client.setCallback(new TestMqttCallback(client)); + client.setCallback(new TestMqttCallback(client, new CountDownLatch(1))); String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"23\",\"value\": 1}}"; String deviceId = savedDevice.getId().getId().toString(); @@ -204,9 +211,16 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC private static class TestMqttCallback implements MqttCallback { private final MqttAsyncClient client; + private final CountDownLatch latch; + private Integer qoS; - TestMqttCallback(MqttAsyncClient client) { + TestMqttCallback(MqttAsyncClient client, CountDownLatch latch) { this.client = client; + this.latch = latch; + } + + int getQoS() { + return qoS; } @Override @@ -219,7 +233,9 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractC MqttMessage message = new MqttMessage(); String responseTopic = requestTopic.replace("request", "response"); message.setPayload("{\"value1\":\"A\", \"value2\":\"B\"}".getBytes("UTF-8")); + qoS = mqttMessage.getQos(); client.publish(responseTopic, message); + latch.countDown(); } @Override 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 d02d07dff9..f7a7ceafd6 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 @@ -15,10 +15,9 @@ */ package org.thingsboard.server.mqtt.telemetry; +import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; -import org.eclipse.paho.client.mqttv3.MqttAsyncClient; -import org.eclipse.paho.client.mqttv3.MqttConnectOptions; -import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.eclipse.paho.client.mqttv3.*; import org.junit.Before; import org.junit.Ignore; import org.junit.Test; @@ -30,9 +29,12 @@ import org.thingsboard.server.dao.service.DaoNoSqlTest; import java.net.URI; import java.util.*; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** * @author Valerii Sosliuk @@ -94,4 +96,62 @@ public abstract class AbstractMqttTelemetryIntegrationTest extends AbstractContr assertEquals("3.0", values.get("key3").get(0).get("value")); assertEquals("4", values.get("key4").get(0).get("value")); } + + @Test + public void testMqttQoSLevel() throws Exception { + String clientId = MqttAsyncClient.generateClientId(); + MqttAsyncClient client = new MqttAsyncClient(MQTT_URL, clientId); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setUserName(accessToken); + client.connect(options).waitForCompletion(3000); + CountDownLatch latch = new CountDownLatch(1); + TestMqttCallback callback = new TestMqttCallback(client, latch); + client.setCallback(callback); + 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); + assertEquals(payload, callback.getPayload()); + assertEquals(MqttQoS.AT_MOST_ONCE.value(), callback.getQoS()); + } + + private static class TestMqttCallback implements MqttCallback { + + private final MqttAsyncClient client; + private final CountDownLatch latch; + private Integer qoS; + private String payload; + + String getPayload() { + return payload; + } + + TestMqttCallback(MqttAsyncClient client, CountDownLatch latch) { + this.client = client; + this.latch = latch; + } + + int getQoS() { + return qoS; + } + + @Override + public void connectionLost(Throwable throwable) { + } + + @Override + public void messageArrived(String requestTopic, MqttMessage mqttMessage) { + payload = new String(mqttMessage.getPayload()); + qoS = mqttMessage.getQos(); + latch.countDown(); + } + + @Override + public void deliveryComplete(IMqttDeliveryToken iMqttDeliveryToken) { + + } + } + + } diff --git a/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java index bffe4913ef..c1d1e6cbaf 100644 --- a/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/rules/RuleEngineNoSqlTestSuite.java @@ -35,7 +35,8 @@ public class RuleEngineNoSqlTestSuite { public static CustomCassandraCQLUnit cassandraUnit = new CustomCassandraCQLUnit( Arrays.asList( - new ClassPathCQLDataSet("cassandra/schema.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-ts.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-entities.cql", false, false), new ClassPathCQLDataSet("cassandra/system-data.cql", false, false)), "cassandra-test.yaml", 30000l); diff --git a/application/src/test/java/org/thingsboard/server/rules/RuleEngineSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/rules/RuleEngineSqlTestSuite.java index 7b13e2fd54..e09d820777 100644 --- a/application/src/test/java/org/thingsboard/server/rules/RuleEngineSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/rules/RuleEngineSqlTestSuite.java @@ -30,7 +30,7 @@ public class RuleEngineSqlTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql"), "sql/drop-all-tables.sql", "sql-test.properties"); } diff --git a/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java index c86d49609e..1de2787af6 100644 --- a/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/flow/AbstractRuleEngineFlowIntegrationTest.java @@ -38,6 +38,7 @@ import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.controller.AbstractRuleEngineControllerTest; import org.thingsboard.server.dao.attributes.AttributesService; @@ -155,7 +156,7 @@ public abstract class AbstractRuleEngineFlowIntegrationTest extends AbstractRule device.getId(), new TbMsgMetaData(), "{}", null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(device.getId(), new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg))); Thread.sleep(3000); @@ -191,9 +192,6 @@ public abstract class AbstractRuleEngineFlowIntegrationTest extends AbstractRule Assert.assertEquals("serverAttributeValue1", getMetadata(outEvent).get("ss_serverAttributeKey1").asText()); Assert.assertEquals("serverAttributeValue2", getMetadata(outEvent).get("ss_serverAttributeKey2").asText()); - - List unAckMsgList = Lists.newArrayList(msgQueueService.findUnprocessed(savedTenant.getId(), ruleChain.getId().getId(), 0L)); - Assert.assertEquals(0, unAckMsgList.size()); } @Test @@ -273,7 +271,7 @@ public abstract class AbstractRuleEngineFlowIntegrationTest extends AbstractRule device.getId(), new TbMsgMetaData(), "{}", null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg)); + actorService.onMsg(new SendToClusterMsg(device.getId(), new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg))); Thread.sleep(3000); @@ -311,12 +309,6 @@ public abstract class AbstractRuleEngineFlowIntegrationTest extends AbstractRule Assert.assertEquals("serverAttributeValue1", getMetadata(outEvent).get("ss_serverAttributeKey1").asText()); Assert.assertEquals("serverAttributeValue2", getMetadata(outEvent).get("ss_serverAttributeKey2").asText()); - - List unAckMsgList = Lists.newArrayList(msgQueueService.findUnprocessed(savedTenant.getId(), rootRuleChain.getId().getId(), 0L)); - Assert.assertEquals(0, unAckMsgList.size()); - - unAckMsgList = Lists.newArrayList(msgQueueService.findUnprocessed(savedTenant.getId(), secondaryRuleChain.getId().getId(), 0L)); - Assert.assertEquals(0, unAckMsgList.size()); } } diff --git a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java index 7ac0789383..f59dd63614 100644 --- a/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/rules/lifecycle/AbstractRuleEngineLifecycleIntegrationTest.java @@ -39,6 +39,7 @@ import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.cluster.SendToClusterMsg; import org.thingsboard.server.common.msg.system.ServiceToRuleEngineMsg; import org.thingsboard.server.controller.AbstractRuleEngineControllerTest; import org.thingsboard.server.dao.attributes.AttributesService; @@ -142,76 +143,7 @@ public abstract class AbstractRuleEngineLifecycleIntegrationTest extends Abstrac new TbMsgMetaData(), "{}", null, null, 0L); - actorService.onMsg(new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg)); - - Thread.sleep(3000); - - TimePageData eventsPage = getDebugEvents(savedTenant.getId(), ruleChain.getFirstRuleNodeId(), 1000); - List events = eventsPage.getData().stream().filter(filterByCustomEvent()).collect(Collectors.toList()); - - Assert.assertEquals(2, events.size()); - - Event inEvent = events.stream().filter(e -> e.getBody().get("type").asText().equals(DataConstants.IN)).findFirst().get(); - Assert.assertEquals(ruleChain.getFirstRuleNodeId(), inEvent.getEntityId()); - Assert.assertEquals(device.getId().getId().toString(), inEvent.getBody().get("entityId").asText()); - - Event outEvent = events.stream().filter(e -> e.getBody().get("type").asText().equals(DataConstants.OUT)).findFirst().get(); - Assert.assertEquals(ruleChain.getFirstRuleNodeId(), outEvent.getEntityId()); - Assert.assertEquals(device.getId().getId().toString(), outEvent.getBody().get("entityId").asText()); - - Assert.assertEquals("serverAttributeValue", getMetadata(outEvent).get("ss_serverAttributeKey").asText()); - } - - @Test - public void testRuleChainWithOneRuleAndMsgFromQueue() throws Exception { - // Creating Rule Chain - RuleChain ruleChain = new RuleChain(); - ruleChain.setName("Simple Rule Chain"); - ruleChain.setTenantId(savedTenant.getId()); - ruleChain.setRoot(true); - ruleChain.setDebugMode(true); - ruleChain = saveRuleChain(ruleChain); - Assert.assertNull(ruleChain.getFirstRuleNodeId()); - - // Saving the device - Device device = new Device(); - device.setName("My device"); - device.setType("default"); - device = doPost("/api/device", device, Device.class); - - attributesService.save(device.getId(), DataConstants.SERVER_SCOPE, - Collections.singletonList(new BaseAttributeKvEntry(new StringDataEntry("serverAttributeKey", "serverAttributeValue"), System.currentTimeMillis()))); - - // Pushing Message to the system - TbMsg tbMsg = new TbMsg(UUIDs.timeBased(), - "CUSTOM", - device.getId(), - new TbMsgMetaData(), - "{}", - ruleChain.getId(), null, 0L); - msgQueueService.put(device.getTenantId(), tbMsg, ruleChain.getId().getId(), 0L); - - Thread.sleep(1000); - - RuleChainMetaData metaData = new RuleChainMetaData(); - metaData.setRuleChainId(ruleChain.getId()); - - RuleNode ruleNode = new RuleNode(); - ruleNode.setName("Simple Rule Node"); - ruleNode.setType(org.thingsboard.rule.engine.metadata.TbGetAttributesNode.class.getName()); - ruleNode.setDebugMode(true); - TbGetAttributesNodeConfiguration configuration = new TbGetAttributesNodeConfiguration(); - configuration.setServerAttributeNames(Collections.singletonList("serverAttributeKey")); - ruleNode.setConfiguration(mapper.valueToTree(configuration)); - - metaData.setNodes(Collections.singletonList(ruleNode)); - metaData.setFirstNodeIndex(0); - - metaData = saveRuleChainMetaData(metaData); - Assert.assertNotNull(metaData); - - ruleChain = getRuleChain(ruleChain.getId()); - Assert.assertNotNull(ruleChain.getFirstRuleNodeId()); + actorService.onMsg(new SendToClusterMsg(device.getId(), new ServiceToRuleEngineMsg(savedTenant.getId(), tbMsg))); Thread.sleep(3000); diff --git a/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java b/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java index ea7038442d..fe0f381893 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/RuleNodeJsScriptEngineTest.java @@ -21,23 +21,30 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; import org.thingsboard.rule.engine.api.ScriptEngine; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import javax.script.ScriptException; - +import java.util.Map; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.Assert.*; public class RuleNodeJsScriptEngineTest { private ScriptEngine scriptEngine; - private TestNashornJsSandboxService jsSandboxService; + private TestNashornJsInvokeService jsSandboxService; + + private EntityId ruleNodeId = new RuleNodeId(UUIDs.timeBased()); @Before public void beforeTest() throws Exception { - jsSandboxService = new TestNashornJsSandboxService(false, 1, 100, 3); + jsSandboxService = new TestNashornJsInvokeService(false, 1, 100, 3); } @After @@ -48,7 +55,7 @@ public class RuleNodeJsScriptEngineTest { @Test public void msgCanBeUpdated() throws ScriptException { String function = "metadata.temp = metadata.temp * 10; return {metadata: metadata};"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, function); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, function); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "7"); @@ -65,7 +72,7 @@ public class RuleNodeJsScriptEngineTest { @Test public void newAttributesCanBeAddedInMsg() throws ScriptException { String function = "metadata.newAttr = metadata.humidity - msg.passed; return {metadata: metadata};"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, function); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, function); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "7"); metaData.putValue("humidity", "99"); @@ -81,7 +88,7 @@ public class RuleNodeJsScriptEngineTest { @Test public void payloadCanBeUpdated() throws ScriptException { String function = "msg.passed = msg.passed * metadata.temp; msg.bigObj.newProp = 'Ukraine'; return {msg: msg};"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, function); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, function); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "7"); metaData.putValue("humidity", "99"); @@ -99,7 +106,7 @@ public class RuleNodeJsScriptEngineTest { @Test public void metadataAccessibleForFilter() throws ScriptException { String function = "return metadata.humidity < 15;"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, function); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, function); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "7"); metaData.putValue("humidity", "99"); @@ -113,7 +120,7 @@ public class RuleNodeJsScriptEngineTest { @Test public void dataAccessibleForFilter() throws ScriptException { String function = "return msg.passed < 15 && msg.name === 'Vit' && metadata.temp == 7 && msg.bigObj.prop == 42;"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, function); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, function); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "7"); metaData.putValue("humidity", "99"); @@ -134,7 +141,7 @@ public class RuleNodeJsScriptEngineTest { "};\n" + "\n" + "return nextRelation(metadata, msg);"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, jsCode); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, jsCode); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "10"); metaData.putValue("humidity", "99"); @@ -156,7 +163,7 @@ public class RuleNodeJsScriptEngineTest { "};\n" + "\n" + "return nextRelation(metadata, msg);"; - scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, jsCode); + scriptEngine = new RuleNodeJsScriptEngine(jsSandboxService, ruleNodeId, jsCode); TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("temp", "10"); metaData.putValue("humidity", "99"); @@ -168,4 +175,75 @@ public class RuleNodeJsScriptEngineTest { scriptEngine.destroy(); } + @Test + public void concurrentReleasedCorrectly() throws InterruptedException, ExecutionException { + String code = "metadata.temp = metadata.temp * 10; return {metadata: metadata};"; + + int repeat = 1000; + ExecutorService service = Executors.newFixedThreadPool(repeat); + Map scriptIds = new ConcurrentHashMap<>(); + CountDownLatch startLatch = new CountDownLatch(repeat); + CountDownLatch finishLatch = new CountDownLatch(repeat); + AtomicInteger failedCount = new AtomicInteger(0); + + for (int i = 0; i < repeat; i++) { + service.submit(() -> runScript(startLatch, finishLatch, failedCount, scriptIds, code)); + } + + finishLatch.await(); + assertTrue(scriptIds.size() == 1); + assertTrue(failedCount.get() == 0); + + CountDownLatch nextStart = new CountDownLatch(repeat); + CountDownLatch nextFinish = new CountDownLatch(repeat); + for (int i = 0; i < repeat; i++) { + service.submit(() -> runScript(nextStart, nextFinish, failedCount, scriptIds, code)); + } + + nextFinish.await(); + assertTrue(scriptIds.size() == 1); + assertTrue(failedCount.get() == 0); + service.shutdownNow(); + } + + @Test + public void concurrentFailedEvaluationShouldThrowException() throws InterruptedException { + String code = "metadata.temp = metadata.temp * 10; urn {metadata: metadata};"; + + int repeat = 10000; + ExecutorService service = Executors.newFixedThreadPool(repeat); + Map scriptIds = new ConcurrentHashMap<>(); + CountDownLatch startLatch = new CountDownLatch(repeat); + CountDownLatch finishLatch = new CountDownLatch(repeat); + AtomicInteger failedCount = new AtomicInteger(0); + for (int i = 0; i < repeat; i++) { + service.submit(() -> { + service.submit(() -> runScript(startLatch, finishLatch, failedCount, scriptIds, code)); + }); + } + + finishLatch.await(); + assertTrue(scriptIds.isEmpty()); + assertEquals(repeat, failedCount.get()); + service.shutdownNow(); + } + + private void runScript(CountDownLatch startLatch, CountDownLatch finishLatch, AtomicInteger failedCount, + Map scriptIds, String code) { + try { + for (int k = 0; k < 10; k++) { + startLatch.countDown(); + startLatch.await(); + UUID scriptId = jsSandboxService.eval(JsScriptType.RULE_NODE_SCRIPT, code).get(); + scriptIds.put(scriptId, new Object()); + jsSandboxService.invokeFunction(scriptId, "{}", "{}", "TEXT").get(); + jsSandboxService.release(scriptId).get(); + } + } catch (Throwable th) { + failedCount.incrementAndGet(); + } finally { + finishLatch.countDown(); + } + } + } \ No newline at end of file diff --git a/application/src/test/java/org/thingsboard/server/service/script/TestNashornJsSandboxService.java b/application/src/test/java/org/thingsboard/server/service/script/TestNashornJsInvokeService.java similarity index 73% rename from application/src/test/java/org/thingsboard/server/service/script/TestNashornJsSandboxService.java rename to application/src/test/java/org/thingsboard/server/service/script/TestNashornJsInvokeService.java index 731c4bb147..3a83f40bf3 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TestNashornJsSandboxService.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TestNashornJsInvokeService.java @@ -16,23 +16,14 @@ package org.thingsboard.server.service.script; -import com.google.common.util.concurrent.ListenableFuture; -import delight.nashornsandbox.NashornSandbox; -import delight.nashornsandbox.NashornSandboxes; - -import javax.script.ScriptException; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -public class TestNashornJsSandboxService extends AbstractNashornJsSandboxService { +public class TestNashornJsInvokeService extends AbstractNashornJsInvokeService { private boolean useJsSandbox; private final int monitorThreadPoolSize; private final long maxCpuTime; private final int maxErrors; - public TestNashornJsSandboxService(boolean useJsSandbox, int monitorThreadPoolSize, long maxCpuTime, int maxErrors) { + public TestNashornJsInvokeService(boolean useJsSandbox, int monitorThreadPoolSize, long maxCpuTime, int maxErrors) { this.useJsSandbox = useJsSandbox; this.monitorThreadPoolSize = monitorThreadPoolSize; this.maxCpuTime = maxCpuTime; diff --git a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java index 5e6896b2b7..1ebccadfea 100644 --- a/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java +++ b/application/src/test/java/org/thingsboard/server/system/BaseHttpDeviceApiTest.java @@ -57,7 +57,7 @@ public abstract class BaseHttpDeviceApiTest extends AbstractControllerTest { @Test public void testGetAttributes() throws Exception { doGetAsync("/api/v1/" + "WRONG_TOKEN" + "/attributes?clientKeys=keyA,keyB,keyC").andExpect(status().isUnauthorized()); - doGetAsync("/api/v1/" + deviceCredentials.getCredentialsId() + "/attributes?clientKeys=keyA,keyB,keyC").andExpect(status().isNotFound()); + doGetAsync("/api/v1/" + deviceCredentials.getCredentialsId() + "/attributes?clientKeys=keyA,keyB,keyC").andExpect(status().isOk()); Map attrMap = new HashMap<>(); attrMap.put("keyA", "valueA"); diff --git a/application/src/test/java/org/thingsboard/server/system/SystemNoSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/system/SystemNoSqlTestSuite.java index 70e3fe2147..ebde304fd6 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemNoSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemNoSqlTestSuite.java @@ -34,7 +34,8 @@ public class SystemNoSqlTestSuite { public static CustomCassandraCQLUnit cassandraUnit = new CustomCassandraCQLUnit( Arrays.asList( - new ClassPathCQLDataSet("cassandra/schema.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-ts.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-entities.cql", false, false), new ClassPathCQLDataSet("cassandra/system-data.cql", false, false)), "cassandra-test.yaml", 30000l); } diff --git a/application/src/test/java/org/thingsboard/server/system/SystemSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/system/SystemSqlTestSuite.java index 97c67491f1..8ca6dcc877 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemSqlTestSuite.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemSqlTestSuite.java @@ -31,7 +31,7 @@ public class SystemSqlTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql"), "sql/drop-all-tables.sql", "sql-test.properties"); diff --git a/application/src/test/resources/logback.xml b/application/src/test/resources/logback.xml index b77027a761..47dacce343 100644 --- a/application/src/test/resources/logback.xml +++ b/application/src/test/resources/logback.xml @@ -9,7 +9,7 @@ - + diff --git a/common/data/pom.xml b/common/data/pom.xml index b99e6bf79f..0fef87ab56 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT common org.thingsboard.common diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index 21de402721..853caff8d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -19,5 +19,7 @@ public class CacheConstants { public static final String DEVICE_CREDENTIALS_CACHE = "deviceCredentials"; public static final String RELATIONS_CACHE = "relations"; public static final String DEVICE_CACHE = "devices"; + public static final String SESSIONS_CACHE = "sessions"; public static final String ASSET_CACHE = "assets"; + public static final String ENTITY_VIEW_CACHE = "entityViews"; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java index 34c14deb8c..307c59bdb9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DataConstants.java @@ -57,6 +57,8 @@ public class DataConstants { public static final String ENTITY_UNASSIGNED = "ENTITY_UNASSIGNED"; public static final String ATTRIBUTES_UPDATED = "ATTRIBUTES_UPDATED"; public static final String ATTRIBUTES_DELETED = "ATTRIBUTES_DELETED"; + public static final String ALARM_ACK = "ALARM_ACK"; + public static final String ALARM_CLEAR = "ALARM_CLEAR"; public static final String RPC_CALL_FROM_SERVER_TO_DEVICE = "RPC_CALL_FROM_SERVER_TO_DEVICE"; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java index fe9c018718..ef4994adda 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityType.java @@ -19,5 +19,5 @@ package org.thingsboard.server.common.data; * @author Andrew Shvayka */ public enum EntityType { - TENANT, CUSTOMER, USER, DASHBOARD, ASSET, DEVICE, ALARM, RULE_CHAIN, RULE_NODE; + TENANT, CUSTOMER, USER, DASHBOARD, ASSET, DEVICE, ALARM, RULE_CHAIN, RULE_NODE, ENTITY_VIEW } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java new file mode 100644 index 0000000000..f684e2afa9 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; + +/** + * Created by Victor Basanets on 8/27/2017. + */ + +@Data +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class EntityView extends SearchTextBasedWithAdditionalInfo + implements HasName, HasTenantId, HasCustomerId { + + private static final long serialVersionUID = 5582010124562018986L; + + private EntityId entityId; + private TenantId tenantId; + private CustomerId customerId; + private String name; + private String type; + private TelemetryEntityView keys; + private long startTimeMs; + private long endTimeMs; + + public EntityView() { + super(); + } + + public EntityView(EntityViewId id) { + super(id); + } + + public EntityView(EntityView entityView) { + super(entityView); + } + + @Override + public String getSearchText() { + return getName() /*What the ...*/; + } + + @Override + public CustomerId getCustomerId() { + return customerId; + } + + @Override + public String getName() { + return name; + } + + @Override + public TenantId getTenantId() { + return tenantId; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java index c37d4607c3..822387cfcb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java @@ -24,6 +24,7 @@ public enum ActionType { UPDATED(false), // log entity ATTRIBUTES_UPDATED(false), // log attributes/values ATTRIBUTES_DELETED(false), // log attributes + TIMESERIES_DELETED(false), // log timeseries RPC_CALL(false), // log method and params CREDENTIALS_UPDATED(false), // log new credentials ASSIGNED_TO_CUSTOMER(false), // log customer name @@ -32,11 +33,11 @@ public enum ActionType { SUSPENDED(false), // log string id CREDENTIALS_READ(true), // log device id ATTRIBUTES_READ(true), // log attributes - RELATION_ADD_OR_UPDATE (false), - RELATION_DELETED (false), - RELATIONS_DELETED (false), - ALARM_ACK (false), - ALARM_CLEAR (false); + RELATION_ADD_OR_UPDATE(false), + RELATION_DELETED(false), + RELATIONS_DELETED(false), + ALARM_ACK(false), + ALARM_CLEAR(false); private final boolean isRead; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java new file mode 100644 index 0000000000..3bf08ea0b4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/entityview/EntityViewSearchQuery.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.entityview; + +import lombok.Data; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntityRelationsQuery; +import org.thingsboard.server.common.data.relation.EntityTypeFilter; +import org.thingsboard.server.common.data.relation.RelationsSearchParameters; + +import java.util.Collections; +import java.util.List; + +@Data +public class EntityViewSearchQuery { + + private RelationsSearchParameters parameters; + private String relationType; + private List entityViewTypes; + + public EntityRelationsQuery toEntitySearchQuery() { + EntityRelationsQuery query = new EntityRelationsQuery(); + query.setParameters(parameters); + query.setFilters( + Collections.singletonList(new EntityTypeFilter(relationType == null ? EntityRelation.CONTAINS_TYPE : relationType, + Collections.singletonList(EntityType.ENTITY_VIEW)))); + return query; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index ed4cf2f784..4e35c0b2df 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -57,6 +57,8 @@ public class EntityIdFactory { return new RuleChainId(uuid); case RULE_NODE: return new RuleNodeId(uuid); + case ENTITY_VIEW: + return new EntityViewId(uuid); } throw new IllegalArgumentException("EntityType " + type + " is not supported!"); } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseNotification.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java similarity index 51% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseNotification.java rename to common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java index bf36a9b3c3..459dd990a4 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseNotification.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java @@ -13,26 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; +package org.thingsboard.server.common.data.id; -import lombok.ToString; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.thingsboard.server.common.data.EntityType; -@ToString -public class SessionCloseNotification implements ToDeviceMsg { +import java.util.UUID; + +/** + * Created by Victor Basanets on 8/27/2017. + */ +public class EntityViewId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; - @Override - public boolean isSuccess() { - return true; + @JsonCreator + public EntityViewId(@JsonProperty("id") UUID id) { + super(id); } - public SessionMsgType getSessionMsgType() { - return SessionMsgType.SESSION_CLOSE; + public static EntityViewId fromString(String entityViewID) { + return new EntityViewId(UUID.fromString(entityViewID)); } + @Override + public EntityType getEntityType() { + return EntityType.ENTITY_VIEW; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java index 3c48adfc18..3e4e8ef7a5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/BaseReadTsKvQuery.java @@ -42,4 +42,8 @@ public class BaseReadTsKvQuery extends BaseTsKvQuery implements ReadTsKvQuery { this(key, startTs, endTs, endTs - startTs, 1, Aggregation.AVG, "DESC"); } + public BaseReadTsKvQuery(String key, long startTs, long endTs, int limit, String orderBy) { + this(key, startTs, endTs, endTs - startTs, limit, Aggregation.NONE, orderBy); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java new file mode 100644 index 0000000000..d125f26146 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/AttributesEntityView.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.objects; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * Created by Victor Basanets on 9/05/2017. + */ +@Data +@NoArgsConstructor +public class AttributesEntityView implements Serializable { + + private List cs = new ArrayList<>(); + private List ss = new ArrayList<>(); + private List sh = new ArrayList<>(); + + public AttributesEntityView(List cs, + List ss, + List sh) { + + this.cs = new ArrayList<>(cs); + this.ss = new ArrayList<>(ss); + this.sh = new ArrayList<>(sh); + } + + public AttributesEntityView(AttributesEntityView obj) { + this(obj.getCs(), obj.getSs(), obj.getSh()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java similarity index 51% rename from application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java rename to common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java index d015fe01de..6b9f5dd8ef 100644 --- a/application/src/main/java/org/thingsboard/server/actors/shared/SessionTimeoutMsg.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/objects/TelemetryEntityView.java @@ -13,24 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.actors.shared; +package org.thingsboard.server.common.data.objects; import lombok.Data; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.TbActorMsg; +import lombok.NoArgsConstructor; import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +/** + * Created by Victor Basanets on 9/05/2017. + */ @Data -public class SessionTimeoutMsg implements Serializable, TbActorMsg { +@NoArgsConstructor +public class TelemetryEntityView implements Serializable { - private static final long serialVersionUID = 1L; + private List timeseries; + private AttributesEntityView attributes; - private final SessionId sessionId; + public TelemetryEntityView(List timeseries, AttributesEntityView attributes) { + + this.timeseries = new ArrayList<>(timeseries); + this.attributes = attributes; + } - @Override - public MsgType getMsgType() { - return MsgType.SESSION_TIMEOUT_MSG; + public TelemetryEntityView(TelemetryEntityView obj) { + this(obj.getTimeseries(), obj.getAttributes()); } } diff --git a/common/message/pom.xml b/common/message/pom.xml index 6923cacdf5..d914d4f21d 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT common org.thingsboard.common @@ -40,6 +40,10 @@ org.thingsboard.common data + + org.bouncycastle + bcprov-jdk15on + org.slf4j slf4j-api diff --git a/dao/src/main/java/org/thingsboard/server/dao/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java similarity index 97% rename from dao/src/main/java/org/thingsboard/server/dao/EncryptionUtil.java rename to common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java index a44ad57e19..41a608763a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/EncryptionUtil.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao; +package org.thingsboard.server.common.msg; import lombok.extern.slf4j.Slf4j; import org.bouncycastle.crypto.digests.SHA3Digest; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java index 60e5469fa5..44a98d6ee4 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/MsgType.java @@ -77,11 +77,6 @@ public enum MsgType { */ RULE_TO_SELF_MSG, - /** - * Message that is sent by Session Actor to Device Actor. Represents messages from the device itself. - */ - DEVICE_SESSION_TO_DEVICE_ACTOR_MSG, - DEVICE_ATTRIBUTES_UPDATE_TO_DEVICE_ACTOR_MSG, DEVICE_CREDENTIALS_UPDATE_TO_DEVICE_ACTOR_MSG, @@ -96,21 +91,19 @@ public enum MsgType { DEVICE_ACTOR_CLIENT_SIDE_RPC_TIMEOUT_MSG, - DEVICE_ACTOR_QUEUE_TIMEOUT_MSG, - /** * Message that is sent from the Device Actor to Rule Engine. Requires acknowledgement */ DEVICE_ACTOR_TO_RULE_ENGINE_MSG, + SESSION_TIMEOUT_MSG, + + STATS_PERSIST_TICK_MSG, + + /** - * Message that is sent from Rule Engine to the Device Actor when message is successfully pushed to queue. + * Message that is sent by TransportRuleEngineService to Device Actor. Represents messages from the device itself. */ - RULE_ENGINE_QUEUE_PUT_ACK_MSG, - ACTOR_SYSTEM_TO_DEVICE_SESSION_ACTOR_MSG, - TRANSPORT_TO_DEVICE_SESSION_ACTOR_MSG, - SESSION_TIMEOUT_MSG, - SESSION_CTRL_MSG, - STATS_PERSIST_TICK_MSG; + TRANSPORT_TO_DEVICE_ACTOR_MSG; } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerAddress.java b/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerAddress.java index 4b65d6fe54..60c9d12b51 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerAddress.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerAddress.java @@ -29,6 +29,7 @@ public class ServerAddress implements Comparable, Serializable { private final String host; private final int port; + private final ServerType serverType; @Override public int compareTo(ServerAddress o) { diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/AdaptorToSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java similarity index 81% rename from common/message/src/main/java/org/thingsboard/server/common/msg/session/AdaptorToSessionActorMsg.java rename to common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java index 5383a5253e..08f74fbe54 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/AdaptorToSessionActorMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/cluster/ServerType.java @@ -13,10 +13,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.session; - -public interface AdaptorToSessionActorMsg extends SessionMsg { - - FromDeviceMsg getMsg(); +package org.thingsboard.server.common.msg.cluster; +/** + * Created by ashvayka on 23.09.18. + */ +public enum ServerType { + CORE } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateNotification.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateNotification.java deleted file mode 100644 index 1f0f9ddc99..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateNotification.java +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.ToString; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -@ToString -public class AttributesUpdateNotification implements ToDeviceMsg { - - private static final long serialVersionUID = 1L; - - private AttributesKVMsg data; - - public AttributesUpdateNotification(AttributesKVMsg data) { - this.data = data; - } - - @Override - public boolean isSuccess() { - return true; - } - - public SessionMsgType getSessionMsgType() { - return SessionMsgType.ATTRIBUTES_UPDATE_NOTIFICATION; - } - - public AttributesKVMsg getData() { - return data; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateRequest.java deleted file mode 100644 index d4cb4b1ae6..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUpdateRequest.java +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.util.Set; - -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg; - -public interface AttributesUpdateRequest extends FromDeviceRequestMsg { - - Set getAttributes(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicActorSystemToDeviceSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicActorSystemToDeviceSessionActorMsg.java deleted file mode 100644 index cc298915c8..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicActorSystemToDeviceSessionActorMsg.java +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -public class BasicActorSystemToDeviceSessionActorMsg implements ActorSystemToDeviceSessionActorMsg { - - private final ToDeviceMsg msg; - private final SessionId sessionId; - - public BasicActorSystemToDeviceSessionActorMsg(ToDeviceMsg msg, SessionId sessionId) { - super(); - this.msg = msg; - this.sessionId = sessionId; - } - - @Override - public SessionId getSessionId() { - return sessionId; - } - - @Override - public ToDeviceMsg getMsg() { - return msg; - } - - @Override - public String toString() { - return "BasicActorSystemToDeviceSessionActorMsg [msg=" + msg + ", sessionId=" + sessionId + "]"; - } - - @Override - public MsgType getMsgType() { - return MsgType.ACTOR_SYSTEM_TO_DEVICE_SESSION_ACTOR_MSG; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicAttributesUpdateRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicAttributesUpdateRequest.java deleted file mode 100644 index 76efca0f07..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicAttributesUpdateRequest.java +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.util.Collection; -import java.util.LinkedHashSet; -import java.util.Set; - -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -public class BasicAttributesUpdateRequest extends BasicRequest implements AttributesUpdateRequest { - - private static final long serialVersionUID = 1L; - - private final Set data; - - public BasicAttributesUpdateRequest() { - this(DEFAULT_REQUEST_ID); - } - - public BasicAttributesUpdateRequest(Integer requestId) { - super(requestId); - this.data = new LinkedHashSet<>(); - } - - public void add(AttributeKvEntry entry) { - this.data.add(entry); - } - - public void add(Collection entries) { - this.data.addAll(entries); - } - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.POST_ATTRIBUTES_REQUEST; - } - - @Override - public Set getAttributes() { - return data; - } - - @Override - public String toString() { - return "BasicAttributesUpdateRequest [data=" + data + "]"; - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicCommandAckResponse.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicCommandAckResponse.java deleted file mode 100644 index 3ee04ba216..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicCommandAckResponse.java +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -public class BasicCommandAckResponse extends BasicResponseMsg implements StatusCodeResponse { - - private static final long serialVersionUID = 1L; - - public static BasicCommandAckResponse onSuccess(SessionMsgType requestMsgType, Integer requestId) { - return BasicCommandAckResponse.onSuccess(requestMsgType, requestId, 200); - } - - public static BasicCommandAckResponse onSuccess(SessionMsgType requestMsgType, Integer requestId, Integer code) { - return new BasicCommandAckResponse(requestMsgType, requestId, true, null, code); - } - - public static BasicCommandAckResponse onError(SessionMsgType requestMsgType, Integer requestId, Exception error) { - return new BasicCommandAckResponse(requestMsgType, requestId, false, error, null); - } - - private BasicCommandAckResponse(SessionMsgType requestMsgType, Integer requestId, boolean success, Exception error, Integer code) { - super(requestMsgType, requestId, SessionMsgType.TO_DEVICE_RPC_RESPONSE_ACK, success, error, code); - } - - @Override - public String toString() { - return "BasicStatusCodeResponse []"; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesRequest.java deleted file mode 100644 index e0f6d7ecc4..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesRequest.java +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.ToString; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -import java.util.Collections; -import java.util.Optional; -import java.util.Set; - -@ToString -public class BasicGetAttributesRequest extends BasicRequest implements GetAttributesRequest { - - private static final long serialVersionUID = 1L; - - private final Set clientKeys; - private final Set sharedKeys; - - public BasicGetAttributesRequest(Integer requestId) { - this(requestId, Collections.emptySet(), Collections.emptySet()); - } - - public BasicGetAttributesRequest(Integer requestId, Set clientKeys, Set sharedKeys) { - super(requestId); - this.clientKeys = clientKeys; - this.sharedKeys = sharedKeys; - } - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.GET_ATTRIBUTES_REQUEST; - } - - @Override - public Optional> getClientAttributeNames() { - return Optional.ofNullable(clientKeys); - } - - @Override - public Optional> getSharedAttributeNames() { - return Optional.ofNullable(sharedKeys); - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesResponse.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesResponse.java deleted file mode 100644 index e3eb15d807..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicGetAttributesResponse.java +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.ToString; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -@ToString -public class BasicGetAttributesResponse extends BasicResponseMsg implements GetAttributesResponse { - - private static final long serialVersionUID = 1L; - - public static BasicGetAttributesResponse onSuccess(SessionMsgType requestMsgType, int requestId, AttributesKVMsg code) { - return new BasicGetAttributesResponse(requestMsgType, requestId, true, null, code); - } - - public static BasicGetAttributesResponse onError(SessionMsgType requestMsgType, int requestId, Exception error) { - return new BasicGetAttributesResponse(requestMsgType, requestId, false, error, null); - } - - private BasicGetAttributesResponse(SessionMsgType requestMsgType, int requestId, boolean success, Exception error, AttributesKVMsg code) { - super(requestMsgType, requestId, SessionMsgType.GET_ATTRIBUTES_RESPONSE, success, error, code); - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicResponseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicResponseMsg.java deleted file mode 100644 index c61d8e5e03..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicResponseMsg.java +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.io.Serializable; -import java.util.Optional; - -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - - -public class BasicResponseMsg implements ResponseMsg { - - private static final long serialVersionUID = 1L; - - private final SessionMsgType requestMsgType; - private final Integer requestId; - private final SessionMsgType sessionMsgType; - private final boolean success; - private final T data; - private final Exception error; - - protected BasicResponseMsg(SessionMsgType requestMsgType, Integer requestId, SessionMsgType sessionMsgType, boolean success, Exception error, T data) { - super(); - this.requestMsgType = requestMsgType; - this.requestId = requestId; - this.sessionMsgType = sessionMsgType; - this.success = success; - this.error = error; - this.data = data; - } - - @Override - public SessionMsgType getRequestMsgType() { - return requestMsgType; - } - - @Override - public Integer getRequestId() { - return requestId; - } - - @Override - public boolean isSuccess() { - return success; - } - - @Override - public Optional getError() { - return Optional.ofNullable(error); - } - - @Override - public Optional getData() { - return Optional.ofNullable(data); - } - - @Override - public String toString() { - return "BasicResponseMsg [success=" + success + ", data=" + data + ", error=" + error + "]"; - } - - public SessionMsgType getSessionMsgType() { - return sessionMsgType; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicStatusCodeResponse.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicStatusCodeResponse.java deleted file mode 100644 index f21aa857bf..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicStatusCodeResponse.java +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.ToString; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -@ToString -public class BasicStatusCodeResponse extends BasicResponseMsg implements StatusCodeResponse { - - private static final long serialVersionUID = 1L; - - public static BasicStatusCodeResponse onSuccess(SessionMsgType requestMsgType, Integer requestId) { - return BasicStatusCodeResponse.onSuccess(requestMsgType, requestId, 0); - } - - public static BasicStatusCodeResponse onSuccess(SessionMsgType requestMsgType, Integer requestId, Integer code) { - return new BasicStatusCodeResponse(requestMsgType, requestId, true, null, code); - } - - public static BasicStatusCodeResponse onError(SessionMsgType requestMsgType, Integer requestId, Exception error) { - return new BasicStatusCodeResponse(requestMsgType, requestId, false, error, null); - } - - private BasicStatusCodeResponse(SessionMsgType requestMsgType, Integer requestId, boolean success, Exception error, Integer code) { - super(requestMsgType, requestId, SessionMsgType.STATUS_CODE_RESPONSE, success, error, code); - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicTelemetryUploadRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicTelemetryUploadRequest.java deleted file mode 100644 index 60faeb1829..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/BasicTelemetryUploadRequest.java +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -public class BasicTelemetryUploadRequest extends BasicRequest implements TelemetryUploadRequest { - - private static final long serialVersionUID = 1L; - - private final Map> data; - - public BasicTelemetryUploadRequest() { - this(DEFAULT_REQUEST_ID); - } - - public BasicTelemetryUploadRequest(Integer requestId) { - super(requestId); - this.data = new HashMap<>(); - } - - public void add(long ts, KvEntry entry) { - List tsEntries = data.get(ts); - if (tsEntries == null) { - tsEntries = new ArrayList<>(); - data.put(ts, tsEntries); - } - tsEntries.add(entry); - } - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.POST_TELEMETRY_REQUEST; - } - - @Override - public Map> getData() { - return data; - } - - @Override - public String toString() { - return "BasicTelemetryUploadRequest [data=" + data + "]"; - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesRequest.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesRequest.java deleted file mode 100644 index 241bb6ba1e..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesRequest.java +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.util.Optional; -import java.util.Set; - -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg; - -public interface GetAttributesRequest extends FromDeviceRequestMsg { - - Optional> getClientAttributeNames(); - Optional> getSharedAttributeNames(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ResponseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ResponseMsg.java deleted file mode 100644 index 3e70460dcb..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ResponseMsg.java +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import java.io.Serializable; -import java.util.Optional; - -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -public interface ResponseMsg extends ToDeviceMsg { - - SessionMsgType getRequestMsgType(); - - Integer getRequestId(); - - Optional getError(); - - Optional getData(); -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcUnsubscribeMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcUnsubscribeMsg.java deleted file mode 100644 index 23eb238d79..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RpcUnsubscribeMsg.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -public class RpcUnsubscribeMsg implements FromDeviceMsg { - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineErrorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineErrorMsg.java deleted file mode 100644 index e0ff23b379..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineErrorMsg.java +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.Data; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; - -/** - * @author Andrew Shvayka - */ -@Data -public class RuleEngineErrorMsg implements ToDeviceMsg { - - private final SessionMsgType inSessionMsgType; - private final RuleEngineError error; - - @Override - public boolean isSuccess() { - return false; - } - - public SessionMsgType getSessionMsgType() { - return SessionMsgType.RULE_ENGINE_ERROR; - } - - public String getErrorMsg() { - switch (error) { - case QUEUE_PUT_TIMEOUT: - return "Timeout during persistence of the message to the queue!"; - case SERVER_ERROR: - return "Error during processing of message by the server!"; - case TIMEOUT: - return "Timeout during processing of message by the server!"; - default: - throw new RuntimeException("Error " + error + " is not supported!"); - } - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java deleted file mode 100644 index 28e319e525..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionOpenMsg.java +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.Data; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -@Data -public class SessionOpenMsg implements FromDeviceMsg { - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.SESSION_OPEN; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcResponseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcResponseMsg.java deleted file mode 100644 index 4fa3024947..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToDeviceRpcResponseMsg.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.Data; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -@Data -public class ToDeviceRpcResponseMsg implements FromDeviceMsg { - - private final int requestId; - private final String data; - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.TO_DEVICE_RPC_RESPONSE; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcRequestMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcRequestMsg.java deleted file mode 100644 index 3823acac70..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcRequestMsg.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.core; - -import lombok.Data; -import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; - -/** - * @author Andrew Shvayka - */ -@Data -public class ToServerRpcRequestMsg implements FromDeviceRequestMsg { - - private final Integer requestId; - private final String method; - private final String params; - - @Override - public SessionMsgType getMsgType() { - return SessionMsgType.TO_SERVER_RPC_REQUEST; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcResponseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcResponseMsg.java index 82f44e9113..fa9ef05fe8 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcResponseMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/core/ToServerRpcResponseMsg.java @@ -16,25 +16,16 @@ package org.thingsboard.server.common.msg.core; import lombok.Data; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; /** * @author Andrew Shvayka */ @Data -public class ToServerRpcResponseMsg implements ToDeviceMsg { +public class ToServerRpcResponseMsg { private final int requestId; private final String data; - public SessionMsgType getSessionMsgType() { - return SessionMsgType.TO_SERVER_RPC_RESPONSE; - } - - @Override public boolean isSuccess() { return true; } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/device/BasicDeviceToDeviceActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/device/BasicDeviceToDeviceActorMsg.java deleted file mode 100644 index 92c5105673..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/device/BasicDeviceToDeviceActorMsg.java +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.device; - -import lombok.ToString; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.TransportToDeviceSessionActorMsg; - -import java.util.Optional; - -@ToString -public class BasicDeviceToDeviceActorMsg implements DeviceToDeviceActorMsg { - - private static final long serialVersionUID = -1866795134993115408L; - - private final TenantId tenantId; - private final CustomerId customerId; - private final DeviceId deviceId; - private final SessionId sessionId; - private final SessionType sessionType; - private final ServerAddress serverAddress; - private final FromDeviceMsg msg; - - public BasicDeviceToDeviceActorMsg(DeviceToDeviceActorMsg other, FromDeviceMsg msg) { - this(null, other.getTenantId(), other.getCustomerId(), other.getDeviceId(), other.getSessionId(), other.getSessionType(), msg); - } - - public BasicDeviceToDeviceActorMsg(TransportToDeviceSessionActorMsg msg, SessionType sessionType) { - this(null, msg.getTenantId(), msg.getCustomerId(), msg.getDeviceId(), msg.getSessionId(), sessionType, msg.getSessionMsg().getMsg()); - } - - private BasicDeviceToDeviceActorMsg(ServerAddress serverAddress, TenantId tenantId, CustomerId customerId, DeviceId deviceId, SessionId sessionId, SessionType sessionType, - FromDeviceMsg msg) { - super(); - this.serverAddress = serverAddress; - this.tenantId = tenantId; - this.customerId = customerId; - this.deviceId = deviceId; - this.sessionId = sessionId; - this.sessionType = sessionType; - this.msg = msg; - } - - @Override - public DeviceId getDeviceId() { - return deviceId; - } - - @Override - public CustomerId getCustomerId() { - return customerId; - } - - public TenantId getTenantId() { - return tenantId; - } - - @Override - public SessionId getSessionId() { - return sessionId; - } - - @Override - public SessionType getSessionType() { - return sessionType; - } - - @Override - public Optional getServerAddress() { - return Optional.ofNullable(serverAddress); - } - - @Override - public FromDeviceMsg getPayload() { - return msg; - } - - @Override - public DeviceToDeviceActorMsg toOtherAddress(ServerAddress otherAddress) { - return new BasicDeviceToDeviceActorMsg(otherAddress, tenantId, customerId, deviceId, sessionId, sessionType, msg); - } - - @Override - public MsgType getMsgType() { - return MsgType.DEVICE_SESSION_TO_DEVICE_ACTOR_MSG; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/device/DeviceToDeviceActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/device/DeviceToDeviceActorMsg.java deleted file mode 100644 index fe8d9f2c21..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/device/DeviceToDeviceActorMsg.java +++ /dev/null @@ -1,41 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.device; - -import java.io.Serializable; -import java.util.Optional; - -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.CustomerAwareMsg; -import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; -import org.thingsboard.server.common.msg.aware.TenantAwareMsg; -import org.thingsboard.server.common.msg.cluster.ServerAddress; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionType; - -public interface DeviceToDeviceActorMsg extends TbActorMsg, DeviceAwareMsg, CustomerAwareMsg, TenantAwareMsg, Serializable { - - SessionId getSessionId(); - - SessionType getSessionType(); - - Optional getServerAddress(); - - FromDeviceMsg getPayload(); - - DeviceToDeviceActorMsg toOtherAddress(ServerAddress otherAddress); -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java deleted file mode 100644 index 7b234195a0..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionActorToAdaptorMsg.java +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import java.util.Optional; - -public class BasicSessionActorToAdaptorMsg extends BasicSessionMsg implements SessionActorToAdaptorMsg { - - private final ToDeviceMsg msg; - - public BasicSessionActorToAdaptorMsg(SessionContext ctx, ToDeviceMsg msg) { - super(ctx); - this.msg = msg; - } - - @Override - public ToDeviceMsg getMsg() { - return msg; - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java deleted file mode 100644 index 855cce53d7..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicSessionMsg.java +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import org.thingsboard.server.common.data.id.SessionId; - -public class BasicSessionMsg implements SessionMsg { - - private final SessionContext ctx; - - public BasicSessionMsg(SessionContext ctx) { - super(); - this.ctx = ctx; - } - - @Override - public SessionId getSessionId() { - return ctx.getSessionId(); - } - - @Override - public SessionContext getSessionContext() { - return ctx; - } - - @Override - public String toString() { - return "BasicSessionMsg [ctx=" + ctx + "]"; - } - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java deleted file mode 100644 index 0704e6e1bf..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/BasicTransportToDeviceSessionActorMsg.java +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.MsgType; - -public class BasicTransportToDeviceSessionActorMsg implements TransportToDeviceSessionActorMsg { - - private final TenantId tenantId; - private final CustomerId customerId; - private final DeviceId deviceId; - private final AdaptorToSessionActorMsg msg; - - public BasicTransportToDeviceSessionActorMsg(Device device, AdaptorToSessionActorMsg msg) { - super(); - this.tenantId = device.getTenantId(); - this.customerId = device.getCustomerId(); - this.deviceId = device.getId(); - this.msg = msg; - } - - @Override - public DeviceId getDeviceId() { - return deviceId; - } - - @Override - public CustomerId getCustomerId() { - return customerId; - } - - public TenantId getTenantId() { - return tenantId; - } - - @Override - public SessionId getSessionId() { - return msg.getSessionId(); - } - - @Override - public AdaptorToSessionActorMsg getSessionMsg() { - return msg; - } - - @Override - public String toString() { - return "BasicTransportToDeviceSessionActorMsg [tenantId=" + tenantId + ", customerId=" + customerId + ", deviceId=" + deviceId + ", msg=" + msg - + "]"; - } - - @Override - public MsgType getMsgType() { - return MsgType.TRANSPORT_TO_DEVICE_SESSION_ACTOR_MSG; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java index 73aaab4d6e..7f7a669100 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionContext.java @@ -15,20 +15,11 @@ */ package org.thingsboard.server.common.msg.session; -import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; -import org.thingsboard.server.common.msg.session.ex.SessionException; +import java.util.UUID; -public interface SessionContext extends SessionAwareMsg { +public interface SessionContext { - SessionType getSessionType(); - - void onMsg(SessionActorToAdaptorMsg msg) throws SessionException; - - void onMsg(SessionCtrlMsg msg) throws SessionException; - - boolean isClosed(); - - long getTimeout(); + UUID getSessionId(); + int nextMsgId(); } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionCtrlMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionCtrlMsg.java deleted file mode 100644 index 8082f72184..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionCtrlMsg.java +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; - -public interface SessionCtrlMsg extends SessionAwareMsg, TbActorMsg { - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionMsg.java deleted file mode 100644 index 6b0b0af15d..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionMsg.java +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; - -public interface SessionMsg extends SessionAwareMsg { - - SessionContext getSessionContext(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/ToDeviceMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/ToDeviceMsg.java deleted file mode 100644 index 705e86420b..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/ToDeviceMsg.java +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import java.io.Serializable; - -public interface ToDeviceMsg extends Serializable { - - boolean isSuccess(); - - SessionMsgType getSessionMsgType(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/TransportToDeviceSessionActorMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/TransportToDeviceSessionActorMsg.java deleted file mode 100644 index deaef3d3e5..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/TransportToDeviceSessionActorMsg.java +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session; - -import org.thingsboard.server.common.msg.TbActorMsg; -import org.thingsboard.server.common.msg.aware.CustomerAwareMsg; -import org.thingsboard.server.common.msg.aware.DeviceAwareMsg; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; -import org.thingsboard.server.common.msg.aware.TenantAwareMsg; - -public interface TransportToDeviceSessionActorMsg extends DeviceAwareMsg, CustomerAwareMsg, TenantAwareMsg, SessionAwareMsg, TbActorMsg { - - AdaptorToSessionActorMsg getSessionMsg(); - -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java deleted file mode 100644 index 49fad13236..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/ctrl/SessionCloseMsg.java +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.session.ctrl; - -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; - -public class SessionCloseMsg implements SessionCtrlMsg { - - private final SessionId sessionId; - private final boolean revoked; - private final boolean timeout; - - public static SessionCloseMsg onDisconnect(SessionId sessionId) { - return new SessionCloseMsg(sessionId, false, false); - } - - public static SessionCloseMsg onError(SessionId sessionId) { - return new SessionCloseMsg(sessionId, false, false); - } - - public static SessionCloseMsg onTimeout(SessionId sessionId) { - return new SessionCloseMsg(sessionId, false, true); - } - - public static SessionCloseMsg onCredentialsRevoked(SessionId sessionId) { - return new SessionCloseMsg(sessionId, true, false); - } - - private SessionCloseMsg(SessionId sessionId, boolean unauthorized, boolean timeout) { - super(); - this.sessionId = sessionId; - this.revoked = unauthorized; - this.timeout = timeout; - } - - @Override - public SessionId getSessionId() { - return sessionId; - } - - public boolean isCredentialsRevoked() { - return revoked; - } - - public boolean isTimeout() { - return timeout; - } - - @Override - public MsgType getMsgType() { - return MsgType.SESSION_CTRL_MSG; - } -} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/system/ServiceToRuleEngineMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/system/ServiceToRuleEngineMsg.java index 0792b63c28..7859623d52 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/system/ServiceToRuleEngineMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/system/ServiceToRuleEngineMsg.java @@ -21,11 +21,13 @@ import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.TbMsg; +import java.io.Serializable; + /** * Created by ashvayka on 15.03.18. */ @Data -public final class ServiceToRuleEngineMsg implements TbActorMsg { +public final class ServiceToRuleEngineMsg implements TbActorMsg, Serializable { private final TenantId tenantId; private final TbMsg tbMsg; diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/timeout/DeviceActorQueueTimeoutMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/timeout/DeviceActorQueueTimeoutMsg.java deleted file mode 100644 index 0277983d8b..0000000000 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/timeout/DeviceActorQueueTimeoutMsg.java +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.msg.timeout; - -import org.thingsboard.server.common.msg.MsgType; -import org.thingsboard.server.common.msg.timeout.TimeoutMsg; - -import java.util.UUID; - -/** - * @author Andrew Shvayka - */ -public final class DeviceActorQueueTimeoutMsg extends TimeoutMsg { - - public DeviceActorQueueTimeoutMsg(UUID id, long timeout) { - super(id, timeout); - } - - @Override - public MsgType getMsgType() { - return MsgType.DEVICE_ACTOR_QUEUE_TIMEOUT_MSG; - } -} diff --git a/common/pom.xml b/common/pom.xml index fbff206408..e55d71fcd3 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,10 +20,9 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard - org.thingsboard common pom @@ -37,7 +36,8 @@ data message - transport + queue + transport diff --git a/common/queue/pom.xml b/common/queue/pom.xml new file mode 100644 index 0000000000..765960e6a7 --- /dev/null +++ b/common/queue/pom.xml @@ -0,0 +1,99 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + common + + org.thingsboard.common + queue + jar + + Thingsboard Server Queue components + https://thingsboard.io + + + UTF-8 + ${basedir}/../.. + + + + + org.thingsboard.common + data + + + org.thingsboard.common + message + + + org.apache.kafka + kafka-clients + + + org.springframework + spring-context-support + + + org.springframework.boot + spring-boot-autoconfigure + + + com.google.guava + guava + + + com.google.code.gson + gson + + + org.apache.commons + commons-lang3 + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + junit + junit + test + + + org.mockito + mockito-all + test + + + + diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java new file mode 100644 index 0000000000..386bfc574e --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/AbstractTbKafkaTemplate.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.extern.slf4j.Slf4j; + +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +/** + * Created by ashvayka on 25.09.18. + */ +@Slf4j +public abstract class AbstractTbKafkaTemplate { + protected byte[] uuidToBytes(UUID uuid) { + ByteBuffer buf = ByteBuffer.allocate(16); + buf.putLong(uuid.getMostSignificantBits()); + buf.putLong(uuid.getLeastSignificantBits()); + return buf.array(); + } + + protected static UUID bytesToUuid(byte[] bytes) { + ByteBuffer bb = ByteBuffer.wrap(bytes); + long firstLong = bb.getLong(); + long secondLong = bb.getLong(); + return new UUID(firstLong, secondLong); + } + + protected byte[] stringToBytes(String string) { + return string.getBytes(StandardCharsets.UTF_8); + } + + protected String bytesToString(byte[] data) { + return new String(data, StandardCharsets.UTF_8); + } +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/AsyncCallbackTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/AsyncCallbackTemplate.java new file mode 100644 index 0000000000..b8ad758ef9 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/AsyncCallbackTemplate.java @@ -0,0 +1,66 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; + +import java.util.concurrent.Executor; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** + * Created by ashvayka on 05.10.18. + */ +public class AsyncCallbackTemplate { + + public static void withCallbackAndTimeout(ListenableFuture future, + Consumer onSuccess, + Consumer onFailure, + long timeoutInMs, + ScheduledExecutorService timeoutExecutor, + Executor callbackExecutor) { + future = Futures.withTimeout(future, timeoutInMs, TimeUnit.MILLISECONDS, timeoutExecutor); + withCallback(future, onSuccess, onFailure, callbackExecutor); + } + + public static void withCallback(ListenableFuture future, Consumer onSuccess, + Consumer onFailure, Executor executor) { + FutureCallback callback = new FutureCallback() { + @Override + public void onSuccess(T result) { + try { + onSuccess.accept(result); + } catch (Throwable th) { + onFailure(th); + } + } + + @Override + public void onFailure(Throwable t) { + onFailure.accept(t); + } + }; + if (executor != null) { + Futures.addCallback(future, callback, executor); + } else { + Futures.addCallback(future, callback); + } + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java new file mode 100644 index 0000000000..f6bafef45d --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaAdmin.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import org.apache.kafka.clients.admin.AdminClient; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; + +import java.time.Duration; +import java.util.Collections; +import java.util.Properties; + +/** + * Created by ashvayka on 24.09.18. + */ +public class TBKafkaAdmin { + + AdminClient client; + + public TBKafkaAdmin(TbKafkaSettings settings) { + client = AdminClient.create(settings.toProps()); + } + + public CreateTopicsResult createTopic(NewTopic topic){ + return client.createTopics(Collections.singletonList(topic)); + } +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java new file mode 100644 index 0000000000..c8a1706c3b --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaConsumerTemplate.java @@ -0,0 +1,88 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.Builder; +import lombok.Getter; +import org.apache.kafka.clients.consumer.ConsumerConfig; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.consumer.KafkaConsumer; + +import java.io.IOException; +import java.time.Duration; +import java.util.Collections; +import java.util.Properties; +import java.util.UUID; + +/** + * Created by ashvayka on 24.09.18. + */ +public class TBKafkaConsumerTemplate { + + private final KafkaConsumer consumer; + private final TbKafkaDecoder decoder; + + @Builder.Default + private TbKafkaRequestIdExtractor requestIdExtractor = ((response) -> null); + + @Getter + private final String topic; + + @Builder + private TBKafkaConsumerTemplate(TbKafkaSettings settings, TbKafkaDecoder decoder, + TbKafkaRequestIdExtractor requestIdExtractor, + String clientId, String groupId, String topic, + boolean autoCommit, int autoCommitIntervalMs, + int maxPollRecords) { + Properties props = settings.toProps(); + props.put(ConsumerConfig.CLIENT_ID_CONFIG, clientId); + if (groupId != null) { + props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); + } + props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, autoCommit); + props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, autoCommitIntervalMs); + props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer"); + props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArrayDeserializer"); + if (maxPollRecords > 0) { + props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, maxPollRecords); + } + this.consumer = new KafkaConsumer<>(props); + this.decoder = decoder; + this.requestIdExtractor = requestIdExtractor; + this.topic = topic; + } + + public void subscribe() { + consumer.subscribe(Collections.singletonList(topic)); + } + + public void unsubscribe() { + consumer.unsubscribe(); + } + + public ConsumerRecords poll(Duration duration) { + return consumer.poll(duration); + } + + public T decode(ConsumerRecord record) throws IOException { + return decoder.decode(record.value()); + } + + public UUID extractRequestId(T value) { + return requestIdExtractor.extractRequestId(value); + } +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java new file mode 100644 index 0000000000..ee652f455c --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TBKafkaProducerTemplate.java @@ -0,0 +1,134 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.Builder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.KafkaProducer; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.PartitionInfo; +import org.apache.kafka.common.errors.TopicExistsException; +import org.apache.kafka.common.header.Header; + +import java.util.List; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Future; + +/** + * Created by ashvayka on 24.09.18. + */ +@Slf4j +public class TBKafkaProducerTemplate { + + private final KafkaProducer producer; + private final TbKafkaEncoder encoder; + + @Builder.Default + private TbKafkaEnricher enricher = ((value, responseTopic, requestId) -> value); + + private final TbKafkaPartitioner partitioner; + private ConcurrentMap> partitionInfoMap; + @Getter + private final String defaultTopic; + + @Getter + private final TbKafkaSettings settings; + + @Builder + private TBKafkaProducerTemplate(TbKafkaSettings settings, TbKafkaEncoder encoder, TbKafkaEnricher enricher, + TbKafkaPartitioner partitioner, String defaultTopic) { + Properties props = settings.toProps(); + props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer"); + props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.ByteArraySerializer"); + this.settings = settings; + this.producer = new KafkaProducer<>(props); + this.encoder = encoder; + this.enricher = enricher; + this.partitioner = partitioner; + this.defaultTopic = defaultTopic; + } + + public void init() { + try { + TBKafkaAdmin admin = new TBKafkaAdmin(this.settings); + CreateTopicsResult result = admin.createTopic(new NewTopic(defaultTopic, 100, (short) 1)); + result.all().get(); + } catch (Exception e) { + if ((e instanceof TopicExistsException) || (e.getCause() != null && e.getCause() instanceof TopicExistsException)) { + log.trace("[{}] Topic already exists.", defaultTopic); + } else { + log.info("[{}] Failed to create topic: {}", defaultTopic, e.getMessage(), e); + throw new RuntimeException(e); + } + } + //Maybe this should not be cached, but we don't plan to change size of partitions + this.partitionInfoMap = new ConcurrentHashMap<>(); + this.partitionInfoMap.putIfAbsent(defaultTopic, producer.partitionsFor(defaultTopic)); + } + + T enrich(T value, String responseTopic, UUID requestId) { + if (enricher != null) { + return enricher.enrich(value, responseTopic, requestId); + } else { + return value; + } + } + + public Future send(String key, T value, Callback callback) { + return send(key, value, null, callback); + } + + public Future send(String key, T value, Iterable
headers, Callback callback) { + return send(key, value, null, headers, callback); + } + + public Future send(String key, T value, Long timestamp, Iterable
headers, Callback callback) { + return send(this.defaultTopic, key, value, timestamp, headers, callback); + } + + public Future send(String topic, String key, T value, Iterable
headers, Callback callback) { + return send(topic, key, value, null, headers, callback); + } + + public Future send(String topic, String key, T value, Callback callback) { + return send(topic, key, value, null, null, callback); + } + + public Future send(String topic, String key, T value, Long timestamp, Iterable
headers, Callback callback) { + byte[] data = encoder.encode(value); + ProducerRecord record; + Integer partition = getPartition(topic, key, value, data); + record = new ProducerRecord<>(topic, partition, timestamp, key, data, headers); + return producer.send(record, callback); + } + + private Integer getPartition(String topic, String key, T value, byte[] data) { + if (partitioner == null) { + return null; + } else { + return partitioner.partition(topic, key, value, data, partitionInfoMap.computeIfAbsent(topic, producer::partitionsFor)); + } + } +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceRequestMsg.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaDecoder.java similarity index 77% rename from common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceRequestMsg.java rename to common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaDecoder.java index 1ebeb671a6..746dfda0bb 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/FromDeviceRequestMsg.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaDecoder.java @@ -13,13 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.session; +package org.thingsboard.server.kafka; + +import java.io.IOException; /** - * @author Andrew Shvayka + * Created by ashvayka on 25.09.18. */ -public interface FromDeviceRequestMsg extends FromDeviceMsg { +public interface TbKafkaDecoder { - Integer getRequestId(); + T decode(byte[] data) throws IOException; } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEncoder.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEncoder.java new file mode 100644 index 0000000000..a3616cbf15 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEncoder.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +/** + * Created by ashvayka on 25.09.18. + */ +public interface TbKafkaEncoder { + + byte[] encode(T value); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/SessionId.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEnricher.java similarity index 79% rename from common/data/src/main/java/org/thingsboard/server/common/data/id/SessionId.java rename to common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEnricher.java index 1b4772768f..76a6f388bc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/SessionId.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaEnricher.java @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.id; +package org.thingsboard.server.kafka; -import java.io.Serializable; +import java.util.UUID; -public interface SessionId extends Serializable { +public interface TbKafkaEnricher { - String toUidStr(); + T enrich(T value, String responseTopic, UUID requestId); } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java new file mode 100644 index 0000000000..9f86155611 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaHandler.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import com.google.common.util.concurrent.ListenableFuture; + +/** + * Created by ashvayka on 05.10.18. + */ +public interface TbKafkaHandler { + + ListenableFuture handle(Request request); + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/TelemetryUploadRequest.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaPartitioner.java similarity index 63% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/TelemetryUploadRequest.java rename to common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaPartitioner.java index 52c2f9e161..2d6e0d2f44 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/TelemetryUploadRequest.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaPartitioner.java @@ -13,17 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; +package org.thingsboard.server.kafka; -import java.util.List; -import java.util.Map; +import org.apache.kafka.clients.producer.Partitioner; +import org.apache.kafka.common.PartitionInfo; -import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.FromDeviceRequestMsg; +import java.util.List; -public interface TelemetryUploadRequest extends FromDeviceRequestMsg { +/** + * Created by ashvayka on 25.09.18. + */ +public interface TbKafkaPartitioner extends Partitioner { - Map> getData(); + int partition(String topic, String key, T value, byte[] encodedValue, List partitions); } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaProperty.java similarity index 68% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java rename to common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaProperty.java index cc32c81ad7..784a5c5645 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantRequestLimitPolicy.java +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaProperty.java @@ -13,16 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.transport.quota.tenant; +package org.thingsboard.server.kafka; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.RequestLimitPolicy; -@Component -public class TenantRequestLimitPolicy extends RequestLimitPolicy { +/** + * Created by ashvayka on 25.09.18. + */ +@Data +public class TbKafkaProperty { - public TenantRequestLimitPolicy(@Value("${quota.rule.tenant.limit}") long limit) { - super(limit); - } + private String key; + private String value; } diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestIdExtractor.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestIdExtractor.java new file mode 100644 index 0000000000..4948df54dc --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestIdExtractor.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import java.util.UUID; + +public interface TbKafkaRequestIdExtractor { + + UUID extractRequestId(T value); + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java new file mode 100644 index 0000000000..b18ccc25a8 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaRequestTemplate.java @@ -0,0 +1,200 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.apache.kafka.common.errors.TopicExistsException; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; + +import java.io.IOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; + +/** + * Created by ashvayka on 25.09.18. + */ +@Slf4j +public class TbKafkaRequestTemplate extends AbstractTbKafkaTemplate { + + private final TBKafkaProducerTemplate requestTemplate; + private final TBKafkaConsumerTemplate responseTemplate; + private final ConcurrentMap> pendingRequests; + private final boolean internalExecutor; + private final ExecutorService executor; + private final long maxRequestTimeout; + private final long maxPendingRequests; + private final long pollInterval; + private volatile long tickTs = 0L; + private volatile long tickSize = 0L; + private volatile boolean stopped = false; + + @Builder + public TbKafkaRequestTemplate(TBKafkaProducerTemplate requestTemplate, + TBKafkaConsumerTemplate responseTemplate, + long maxRequestTimeout, + long maxPendingRequests, + long pollInterval, + ExecutorService executor) { + this.requestTemplate = requestTemplate; + this.responseTemplate = responseTemplate; + this.pendingRequests = new ConcurrentHashMap<>(); + this.maxRequestTimeout = maxRequestTimeout; + this.maxPendingRequests = maxPendingRequests; + this.pollInterval = pollInterval; + if (executor != null) { + internalExecutor = false; + this.executor = executor; + } else { + internalExecutor = true; + this.executor = Executors.newSingleThreadExecutor(); + } + } + + public void init() { + try { + TBKafkaAdmin admin = new TBKafkaAdmin(this.requestTemplate.getSettings()); + CreateTopicsResult result = admin.createTopic(new NewTopic(responseTemplate.getTopic(), 1, (short) 1)); + result.all().get(); + } catch (Exception e) { + if ((e instanceof TopicExistsException) || (e.getCause() != null && e.getCause() instanceof TopicExistsException)) { + log.trace("[{}] Topic already exists. ", responseTemplate.getTopic()); + } else { + log.info("[{}] Failed to create topic: {}", responseTemplate.getTopic(), e.getMessage(), e); + throw new RuntimeException(e); + } + + } + this.requestTemplate.init(); + tickTs = System.currentTimeMillis(); + responseTemplate.subscribe(); + executor.submit(() -> { + long nextCleanupMs = 0L; + while (!stopped) { + ConsumerRecords responses = responseTemplate.poll(Duration.ofMillis(pollInterval)); + if (responses.count() > 0) { + log.trace("Polling responses completed, consumer records count [{}]", responses.count()); + } + responses.forEach(response -> { + log.trace("Received response to Kafka Template request: {}", response); + Header requestIdHeader = response.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); + Response decodedResponse = null; + UUID requestId = null; + if (requestIdHeader == null) { + try { + decodedResponse = responseTemplate.decode(response); + requestId = responseTemplate.extractRequestId(decodedResponse); + } catch (IOException e) { + log.error("Failed to decode response", e); + } + } else { + requestId = bytesToUuid(requestIdHeader.value()); + } + if (requestId == null) { + log.error("[{}] Missing requestId in header and body", response); + } else { + log.trace("[{}] Response received", requestId); + ResponseMetaData expectedResponse = pendingRequests.remove(requestId); + if (expectedResponse == null) { + log.trace("[{}] Invalid or stale request", requestId); + } else { + try { + if (decodedResponse == null) { + decodedResponse = responseTemplate.decode(response); + } + expectedResponse.future.set(decodedResponse); + } catch (IOException e) { + expectedResponse.future.setException(e); + } + } + } + }); + tickTs = System.currentTimeMillis(); + tickSize = pendingRequests.size(); + if (nextCleanupMs < tickTs) { + //cleanup; + pendingRequests.entrySet().forEach(kv -> { + if (kv.getValue().expTime < tickTs) { + ResponseMetaData staleRequest = pendingRequests.remove(kv.getKey()); + if (staleRequest != null) { + log.trace("[{}] Request timeout detected, expTime [{}], tickTs [{}]", kv.getKey(), staleRequest.expTime, tickTs); + staleRequest.future.setException(new TimeoutException()); + } + } + }); + nextCleanupMs = tickTs + maxRequestTimeout; + } + } + }); + } + + public void stop() { + stopped = true; + if (internalExecutor) { + executor.shutdownNow(); + } + } + + public ListenableFuture post(String key, Request request) { + if (tickSize > maxPendingRequests) { + return Futures.immediateFailedFuture(new RuntimeException("Pending request map is full!")); + } + UUID requestId = UUID.randomUUID(); + List
headers = new ArrayList<>(2); + headers.add(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId))); + headers.add(new RecordHeader(TbKafkaSettings.RESPONSE_TOPIC_HEADER, stringToBytes(responseTemplate.getTopic()))); + SettableFuture future = SettableFuture.create(); + ResponseMetaData responseMetaData = new ResponseMetaData<>(tickTs + maxRequestTimeout, future); + pendingRequests.putIfAbsent(requestId, responseMetaData); + request = requestTemplate.enrich(request, responseTemplate.getTopic(), requestId); + log.trace("[{}] Sending request, key [{}], expTime [{}]", requestId, key, responseMetaData.expTime); + requestTemplate.send(key, request, headers, (metadata, exception) -> { + if (exception != null) { + log.trace("[{}] Failed to post the request", requestId, exception); + } else { + log.trace("[{}] Posted the request", requestId, metadata); + } + }); + return future; + } + + private static class ResponseMetaData { + private final long expTime; + private final SettableFuture future; + + ResponseMetaData(long ts, SettableFuture future) { + this.expTime = ts; + this.future = future; + } + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java new file mode 100644 index 0000000000..e10cd3c917 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaResponseTemplate.java @@ -0,0 +1,161 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.common.errors.InterruptException; +import org.apache.kafka.common.header.Header; +import org.apache.kafka.common.header.internals.RecordHeader; + +import java.time.Duration; +import java.util.Collections; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Created by ashvayka on 25.09.18. + */ +@Slf4j +public class TbKafkaResponseTemplate extends AbstractTbKafkaTemplate { + + private final TBKafkaConsumerTemplate requestTemplate; + private final TBKafkaProducerTemplate responseTemplate; + private final TbKafkaHandler handler; + private final ConcurrentMap pendingRequests; + private final ExecutorService loopExecutor; + private final ScheduledExecutorService timeoutExecutor; + private final ExecutorService callbackExecutor; + private final int maxPendingRequests; + private final long requestTimeout; + + private final long pollInterval; + private volatile boolean stopped = false; + private final AtomicInteger pendingRequestCount = new AtomicInteger(); + + @Builder + public TbKafkaResponseTemplate(TBKafkaConsumerTemplate requestTemplate, + TBKafkaProducerTemplate responseTemplate, + TbKafkaHandler handler, + long pollInterval, + long requestTimeout, + int maxPendingRequests, + ExecutorService executor) { + this.requestTemplate = requestTemplate; + this.responseTemplate = responseTemplate; + this.handler = handler; + this.pendingRequests = new ConcurrentHashMap<>(); + this.maxPendingRequests = maxPendingRequests; + this.pollInterval = pollInterval; + this.requestTimeout = requestTimeout; + this.callbackExecutor = executor; + this.timeoutExecutor = Executors.newSingleThreadScheduledExecutor(); + this.loopExecutor = Executors.newSingleThreadExecutor(); + } + + public void init() { + this.responseTemplate.init(); + requestTemplate.subscribe(); + loopExecutor.submit(() -> { + while (!stopped) { + try { + while (pendingRequestCount.get() >= maxPendingRequests) { + try { + Thread.sleep(pollInterval); + } catch (InterruptedException e) { + log.trace("Failed to wait until the server has capacity to handle new requests", e); + } + } + ConsumerRecords requests = requestTemplate.poll(Duration.ofMillis(pollInterval)); + requests.forEach(request -> { + Header requestIdHeader = request.headers().lastHeader(TbKafkaSettings.REQUEST_ID_HEADER); + if (requestIdHeader == null) { + log.error("[{}] Missing requestId in header", request); + return; + } + UUID requestId = bytesToUuid(requestIdHeader.value()); + if (requestId == null) { + log.error("[{}] Missing requestId in header and body", request); + return; + } + Header responseTopicHeader = request.headers().lastHeader(TbKafkaSettings.RESPONSE_TOPIC_HEADER); + if (responseTopicHeader == null) { + log.error("[{}] Missing response topic in header", request); + return; + } + String responseTopic = bytesToString(responseTopicHeader.value()); + try { + pendingRequestCount.getAndIncrement(); + Request decodedRequest = requestTemplate.decode(request); + AsyncCallbackTemplate.withCallbackAndTimeout(handler.handle(decodedRequest), + response -> { + pendingRequestCount.decrementAndGet(); + reply(requestId, responseTopic, response); + }, + e -> { + pendingRequestCount.decrementAndGet(); + if (e.getCause() != null && e.getCause() instanceof TimeoutException) { + log.warn("[{}] Timedout to process the request: {}", requestId, request, e); + } else { + log.trace("[{}] Failed to process the request: {}", requestId, request, e); + } + }, + requestTimeout, + timeoutExecutor, + callbackExecutor); + } catch (Throwable e) { + pendingRequestCount.decrementAndGet(); + log.warn("[{}] Failed to process the request: {}", requestId, request, e); + } + }); + } catch (InterruptException ie) { + if (!stopped) { + log.warn("Fetching data from kafka was interrupted.", ie); + } + } catch (Throwable e) { + log.warn("Failed to obtain messages from queue.", e); + try { + Thread.sleep(pollInterval); + } catch (InterruptedException e2) { + log.trace("Failed to wait until the server has capacity to handle new requests", e2); + } + } + } + }); + } + + public void stop() { + stopped = true; + if (timeoutExecutor != null) { + timeoutExecutor.shutdownNow(); + } + if (loopExecutor != null) { + loopExecutor.shutdownNow(); + } + } + + private void reply(UUID requestId, String topic, Response response) { + responseTemplate.send(topic, requestId.toString(), response, Collections.singletonList(new RecordHeader(TbKafkaSettings.REQUEST_ID_HEADER, uuidToBytes(requestId))), null); + } + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java new file mode 100644 index 0000000000..39797fcc9b --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbKafkaSettings.java @@ -0,0 +1,73 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.ProducerConfig; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Properties; + +/** + * Created by ashvayka on 25.09.18. + */ +@Slf4j +@ConditionalOnProperty(prefix = "kafka", value = "enabled", havingValue = "true", matchIfMissing = false) +@Component +public class TbKafkaSettings { + + public static final String REQUEST_ID_HEADER = "requestId"; + public static final String RESPONSE_TOPIC_HEADER = "responseTopic"; + + + @Value("${kafka.bootstrap.servers}") + private String servers; + + @Value("${kafka.acks}") + private String acks; + + @Value("${kafka.retries}") + private int retries; + + @Value("${kafka.batch.size}") + private int batchSize; + + @Value("${kafka.linger.ms}") + private long lingerMs; + + @Value("${kafka.buffer.memory}") + private long bufferMemory; + + @Value("${kafka.other:#{null}}") + private List other; + + public Properties toProps() { + Properties props = new Properties(); + props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, servers); + props.put(ProducerConfig.ACKS_CONFIG, acks); + props.put(ProducerConfig.RETRIES_CONFIG, retries); + props.put(ProducerConfig.BATCH_SIZE_CONFIG, batchSize); + props.put(ProducerConfig.LINGER_MS_CONFIG, lingerMs); + props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, bufferMemory); + if(other != null){ + other.forEach(kv -> props.put(kv.getKey(), kv.getValue())); + } + return props; + } +} diff --git a/common/queue/src/main/java/org/thingsboard/server/kafka/TbNodeIdProvider.java b/common/queue/src/main/java/org/thingsboard/server/kafka/TbNodeIdProvider.java new file mode 100644 index 0000000000..47dd725682 --- /dev/null +++ b/common/queue/src/main/java/org/thingsboard/server/kafka/TbNodeIdProvider.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.kafka; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.annotation.PostConstruct; +import java.net.InetAddress; +import java.net.UnknownHostException; + +/** + * Created by ashvayka on 12.10.18. + */ +@Slf4j +@Component +public class TbNodeIdProvider { + + @Getter + @Value("${cluster.node_id:#{null}}") + private String nodeId; + + @PostConstruct + public void init() { + if (StringUtils.isEmpty(nodeId)) { + try { + nodeId = InetAddress.getLocalHost().getHostName(); + } catch (UnknownHostException e) { + nodeId = org.apache.commons.lang3.RandomStringUtils.randomAlphabetic(10); + } + } + log.info("Current NodeId: {}", nodeId); + } + +} diff --git a/common/queue/src/main/resources/logback.xml b/common/queue/src/main/resources/logback.xml new file mode 100644 index 0000000000..dcfc9301b2 --- /dev/null +++ b/common/queue/src/main/resources/logback.xml @@ -0,0 +1,35 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + \ No newline at end of file diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml new file mode 100644 index 0000000000..d68a6db39f --- /dev/null +++ b/common/transport/coap/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + org.thingsboard.common + 2.2.0-SNAPSHOT + transport + + org.thingsboard.common.transport + coap + jar + + Thingsboard CoAP Transport Common + https://thingsboard.io + + + UTF-8 + ${basedir}/../../.. + + + + + org.thingsboard.common.transport + transport-api + + + org.eclipse.californium + californium-core + + + org.springframework + spring-context-support + + + org.springframework + spring-context + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + org.springframework.boot + spring-boot-starter-test + test + + + junit + junit + test + + + org.mockito + mockito-all + test + + + + diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportContext.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportContext.java new file mode 100644 index 0000000000..13662cf1be --- /dev/null +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportContext.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.coap; + +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.transport.TransportContext; +import org.thingsboard.server.transport.coap.adaptors.CoapTransportAdaptor; + +/** + * Created by ashvayka on 18.10.18. + */ +@Slf4j +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.coap.enabled}'=='true')") +@Component +public class CoapTransportContext extends TransportContext { + + @Getter + @Value("${transport.coap.bind_address}") + private String host; + + @Getter + @Value("${transport.coap.bind_port}") + private Integer port; + + @Getter + @Value("${transport.coap.timeout}") + private Long timeout; + + @Getter + @Autowired + private CoapTransportAdaptor adaptor; + +} diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java new file mode 100644 index 0000000000..9cf36e3096 --- /dev/null +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java @@ -0,0 +1,433 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.coap; + +import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.CoapResource; +import org.eclipse.californium.core.coap.CoAP.ResponseCode; +import org.eclipse.californium.core.coap.Request; +import org.eclipse.californium.core.coap.Response; +import org.eclipse.californium.core.network.Exchange; +import org.eclipse.californium.core.network.ExchangeObserver; +import org.eclipse.californium.core.server.resources.CoapExchange; +import org.eclipse.californium.core.server.resources.Resource; +import org.springframework.util.ReflectionUtils; +import org.thingsboard.server.common.data.security.DeviceTokenCredentials; +import org.thingsboard.server.common.msg.session.FeatureType; +import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.common.transport.TransportContext; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +@Slf4j +public class CoapTransportResource extends CoapResource { + // coap://localhost:port/api/v1/DEVICE_TOKEN/[attributes|telemetry|rpc[/requestId]] + private static final int ACCESS_TOKEN_POSITION = 3; + private static final int FEATURE_TYPE_POSITION = 4; + private static final int REQUEST_ID_POSITION = 5; + + private final CoapTransportContext transportContext; + private final TransportService transportService; + private final Field observerField; + private final long timeout; + private final ConcurrentMap tokenToSessionIdMap = new ConcurrentHashMap<>(); + + public CoapTransportResource(CoapTransportContext context, String name) { + super(name); + this.transportContext = context; + this.transportService = context.getTransportService(); + this.timeout = context.getTimeout(); + // This is important to turn off existing observable logic in + // CoapResource. We will have our own observe monitoring due to 1:1 + // observe relationship. + this.setObservable(false); + observerField = ReflectionUtils.findField(Exchange.class, "observer"); + observerField.setAccessible(true); + } + + @Override + public void handleGET(CoapExchange exchange) { + Optional featureType = getFeatureType(exchange.advanced().getRequest()); + if (!featureType.isPresent()) { + log.trace("Missing feature type parameter"); + exchange.respond(ResponseCode.BAD_REQUEST); + } else if (featureType.get() == FeatureType.TELEMETRY) { + log.trace("Can't fetch/subscribe to timeseries updates"); + exchange.respond(ResponseCode.BAD_REQUEST); + } else if (exchange.getRequestOptions().hasObserve()) { + processExchangeGetRequest(exchange, featureType.get()); + } else if (featureType.get() == FeatureType.ATTRIBUTES) { + processRequest(exchange, SessionMsgType.GET_ATTRIBUTES_REQUEST); + } else { + log.trace("Invalid feature type parameter"); + exchange.respond(ResponseCode.BAD_REQUEST); + } + } + + private void processExchangeGetRequest(CoapExchange exchange, FeatureType featureType) { + boolean unsubscribe = exchange.getRequestOptions().getObserve() == 1; + SessionMsgType sessionMsgType; + if (featureType == FeatureType.RPC) { + sessionMsgType = unsubscribe ? SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST : SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST; + } else { + sessionMsgType = unsubscribe ? SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST : SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST; + } + processRequest(exchange, sessionMsgType); + } + + @Override + public void handlePOST(CoapExchange exchange) { + Optional featureType = getFeatureType(exchange.advanced().getRequest()); + if (!featureType.isPresent()) { + log.trace("Missing feature type parameter"); + exchange.respond(ResponseCode.BAD_REQUEST); + } else { + switch (featureType.get()) { + case ATTRIBUTES: + processRequest(exchange, SessionMsgType.POST_ATTRIBUTES_REQUEST); + break; + case TELEMETRY: + processRequest(exchange, SessionMsgType.POST_TELEMETRY_REQUEST); + break; + case RPC: + Optional requestId = getRequestId(exchange.advanced().getRequest()); + if (requestId.isPresent()) { + processRequest(exchange, SessionMsgType.TO_DEVICE_RPC_RESPONSE); + } else { + processRequest(exchange, SessionMsgType.TO_SERVER_RPC_REQUEST); + } + break; + } + } + } + + private void processRequest(CoapExchange exchange, SessionMsgType type) { + log.trace("Processing {}", exchange.advanced().getRequest()); + exchange.accept(); + Exchange advanced = exchange.advanced(); + Request request = advanced.getRequest(); + + Optional credentials = decodeCredentials(request); + if (!credentials.isPresent()) { + exchange.respond(ResponseCode.BAD_REQUEST); + return; + } + + transportService.process(TransportProtos.ValidateDeviceTokenRequestMsg.newBuilder().setToken(credentials.get().getCredentialsId()).build(), + new DeviceAuthCallback(transportContext, exchange, sessionInfo -> { + UUID sessionId = new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + try { + switch (type) { + case POST_ATTRIBUTES_REQUEST: + transportService.process(sessionInfo, + transportContext.getAdaptor().convertToPostAttributes(sessionId, request), + new CoapOkCallback(exchange)); + break; + case POST_TELEMETRY_REQUEST: + transportService.process(sessionInfo, + transportContext.getAdaptor().convertToPostTelemetry(sessionId, request), + new CoapOkCallback(exchange)); + break; + case SUBSCRIBE_ATTRIBUTES_REQUEST: + advanced.setObserver(new CoapExchangeObserverProxy((ExchangeObserver) observerField.get(advanced), + registerAsyncCoapSession(exchange, request, sessionInfo, sessionId))); + transportService.process(sessionInfo, + TransportProtos.SubscribeToAttributeUpdatesMsg.getDefaultInstance(), + new CoapNoOpCallback(exchange)); + break; + case UNSUBSCRIBE_ATTRIBUTES_REQUEST: + TransportProtos.SessionInfoProto attrSession = lookupAsyncSessionInfo(request); + if (attrSession != null) { + transportService.process(attrSession, + TransportProtos.SubscribeToAttributeUpdatesMsg.newBuilder().setUnsubscribe(true).build(), + new CoapOkCallback(exchange)); + closeAndDeregister(sessionInfo); + } + break; + case SUBSCRIBE_RPC_COMMANDS_REQUEST: + advanced.setObserver(new CoapExchangeObserverProxy((ExchangeObserver) observerField.get(advanced), + registerAsyncCoapSession(exchange, request, sessionInfo, sessionId))); + transportService.process(sessionInfo, + TransportProtos.SubscribeToRPCMsg.getDefaultInstance(), + new CoapNoOpCallback(exchange)); + break; + case UNSUBSCRIBE_RPC_COMMANDS_REQUEST: + TransportProtos.SessionInfoProto rpcSession = lookupAsyncSessionInfo(request); + if (rpcSession != null) { + transportService.process(rpcSession, + TransportProtos.SubscribeToRPCMsg.newBuilder().setUnsubscribe(true).build(), + new CoapOkCallback(exchange)); + transportService.process(sessionInfo, getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null); + transportService.deregisterSession(rpcSession); + } + break; + case TO_DEVICE_RPC_RESPONSE: + transportService.process(sessionInfo, + transportContext.getAdaptor().convertToDeviceRpcResponse(sessionId, request), + new CoapOkCallback(exchange)); + break; + case TO_SERVER_RPC_REQUEST: + transportService.process(sessionInfo, + transportContext.getAdaptor().convertToServerRpcRequest(sessionId, request), + new CoapNoOpCallback(exchange)); + break; + case GET_ATTRIBUTES_REQUEST: + transportService.registerSyncSession(sessionInfo, new CoapSessionListener(sessionId, exchange), transportContext.getTimeout()); + transportService.process(sessionInfo, + transportContext.getAdaptor().convertToGetAttributes(sessionId, request), + new CoapNoOpCallback(exchange)); + break; + } + } catch (AdaptorException e) { + log.trace("[{}] Failed to decode message: ", sessionId, e); + exchange.respond(ResponseCode.BAD_REQUEST); + } catch (IllegalAccessException e) { + log.trace("[{}] Failed to process message: ", sessionId, e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + })); + } + + private TransportProtos.SessionInfoProto lookupAsyncSessionInfo(Request request) { + String token = request.getSource().getHostAddress() + ":" + request.getSourcePort() + ":" + request.getTokenString(); + return tokenToSessionIdMap.remove(token); + } + + private String registerAsyncCoapSession(CoapExchange exchange, Request request, TransportProtos.SessionInfoProto sessionInfo, UUID sessionId) { + String token = request.getSource().getHostAddress() + ":" + request.getSourcePort() + ":" + request.getTokenString(); + tokenToSessionIdMap.putIfAbsent(token, sessionInfo); + CoapSessionListener attrListener = new CoapSessionListener(sessionId, exchange); + transportService.registerAsyncSession(sessionInfo, attrListener); + transportService.process(sessionInfo, getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); + return token; + } + + private static TransportProtos.SessionEventMsg getSessionEventMsg(TransportProtos.SessionEvent event) { + return TransportProtos.SessionEventMsg.newBuilder() + .setSessionType(TransportProtos.SessionType.ASYNC) + .setEvent(event).build(); + } + + private Optional decodeCredentials(Request request) { + List uriPath = request.getOptions().getUriPath(); + if (uriPath.size() >= ACCESS_TOKEN_POSITION) { + return Optional.of(new DeviceTokenCredentials(uriPath.get(ACCESS_TOKEN_POSITION - 1))); + } else { + return Optional.empty(); + } + } + + private Optional getFeatureType(Request request) { + List uriPath = request.getOptions().getUriPath(); + try { + if (uriPath.size() >= FEATURE_TYPE_POSITION) { + return Optional.of(FeatureType.valueOf(uriPath.get(FEATURE_TYPE_POSITION - 1).toUpperCase())); + } + } catch (RuntimeException e) { + log.warn("Failed to decode feature type: {}", uriPath); + } + return Optional.empty(); + } + + public static Optional getRequestId(Request request) { + List uriPath = request.getOptions().getUriPath(); + try { + if (uriPath.size() >= REQUEST_ID_POSITION) { + return Optional.of(Integer.valueOf(uriPath.get(REQUEST_ID_POSITION - 1))); + } + } catch (RuntimeException e) { + log.warn("Failed to decode feature type: {}", uriPath); + } + return Optional.empty(); + } + + @Override + public Resource getChild(String name) { + return this; + } + + private static class DeviceAuthCallback implements TransportServiceCallback { + private final TransportContext transportContext; + private final CoapExchange exchange; + private final Consumer onSuccess; + + DeviceAuthCallback(TransportContext transportContext, CoapExchange exchange, Consumer onSuccess) { + this.transportContext = transportContext; + this.exchange = exchange; + this.onSuccess = onSuccess; + } + + @Override + public void onSuccess(TransportProtos.ValidateDeviceCredentialsResponseMsg msg) { + if (msg.hasDeviceInfo()) { + UUID sessionId = UUID.randomUUID(); + TransportProtos.DeviceInfoProto deviceInfoProto = msg.getDeviceInfo(); + TransportProtos.SessionInfoProto sessionInfo = TransportProtos.SessionInfoProto.newBuilder() + .setNodeId(transportContext.getNodeId()) + .setTenantIdMSB(deviceInfoProto.getTenantIdMSB()) + .setTenantIdLSB(deviceInfoProto.getTenantIdLSB()) + .setDeviceIdMSB(deviceInfoProto.getDeviceIdMSB()) + .setDeviceIdLSB(deviceInfoProto.getDeviceIdLSB()) + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .build(); + onSuccess.accept(sessionInfo); + } else { + exchange.respond(ResponseCode.UNAUTHORIZED); + } + } + + @Override + public void onError(Throwable e) { + log.warn("Failed to process request", e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + private static class CoapOkCallback implements TransportServiceCallback { + private final CoapExchange exchange; + + CoapOkCallback(CoapExchange exchange) { + this.exchange = exchange; + } + + @Override + public void onSuccess(Void msg) { + exchange.respond(ResponseCode.VALID); + } + + @Override + public void onError(Throwable e) { + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + private static class CoapNoOpCallback implements TransportServiceCallback { + private final CoapExchange exchange; + + CoapNoOpCallback(CoapExchange exchange) { + this.exchange = exchange; + } + + @Override + public void onSuccess(Void msg) { + + } + + @Override + public void onError(Throwable e) { + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + public class CoapSessionListener implements SessionMsgListener { + + private final CoapExchange exchange; + private final AtomicInteger seqNumber = new AtomicInteger(2); + + CoapSessionListener(UUID sessionId, CoapExchange exchange) { + this.exchange = exchange; + } + + @Override + public void onGetAttributesResponse(TransportProtos.GetAttributeResponseMsg msg) { + try { + exchange.respond(transportContext.getAdaptor().convertToPublish(this, msg)); + } catch (AdaptorException e) { + log.trace("Failed to reply due to error", e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void onAttributeUpdate(TransportProtos.AttributeUpdateNotificationMsg msg) { + try { + exchange.respond(transportContext.getAdaptor().convertToPublish(this, msg)); + } catch (AdaptorException e) { + log.trace("Failed to reply due to error", e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto sessionCloseNotification) { + exchange.respond(ResponseCode.SERVICE_UNAVAILABLE); + } + + @Override + public void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg msg) { + try { + exchange.respond(transportContext.getAdaptor().convertToPublish(this, msg)); + } catch (AdaptorException e) { + log.trace("Failed to reply due to error", e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + @Override + public void onToServerRpcResponse(TransportProtos.ToServerRpcResponseMsg msg) { + try { + exchange.respond(transportContext.getAdaptor().convertToPublish(this, msg)); + } catch (AdaptorException e) { + log.trace("Failed to reply due to error", e); + exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); + } + } + + public int getNextSeqNumber() { + return seqNumber.getAndIncrement(); + } + } + + public class CoapExchangeObserverProxy implements ExchangeObserver { + + private final ExchangeObserver proxy; + private final String token; + + CoapExchangeObserverProxy(ExchangeObserver proxy, String token) { + super(); + this.proxy = proxy; + this.token = token; + } + + @Override + public void completed(Exchange exchange) { + proxy.completed(exchange); + TransportProtos.SessionInfoProto session = tokenToSessionIdMap.remove(token); + if (session != null) { + closeAndDeregister(session); + } + } + } + + private void closeAndDeregister(TransportProtos.SessionInfoProto session) { + transportService.process(session, getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null); + transportService.deregisterSession(session); + } + +} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java similarity index 68% rename from transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java rename to common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java index 6c8437cb03..c0bd7f9c1b 100644 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportService.java @@ -21,13 +21,12 @@ import org.eclipse.californium.core.CoapServer; import org.eclipse.californium.core.network.CoapEndpoint; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Service; import org.thingsboard.server.common.transport.SessionMsgProcessor; import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.QuotaService; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; import org.thingsboard.server.transport.coap.adaptors.CoapTransportAdaptor; import javax.annotation.PostConstruct; @@ -37,49 +36,26 @@ import java.net.InetSocketAddress; import java.net.UnknownHostException; @Service("CoapTransportService") -@ConditionalOnProperty(prefix = "coap", value = "enabled", havingValue = "true", matchIfMissing = true) +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.coap.enabled}'=='true')") @Slf4j public class CoapTransportService { private static final String V1 = "v1"; private static final String API = "api"; - private CoapServer server; - - @Autowired(required = false) - private ApplicationContext appContext; - - @Autowired(required = false) - private SessionMsgProcessor processor; - - @Autowired(required = false) - private DeviceAuthService authService; + @Autowired + private CoapTransportContext coapTransportContext; - @Autowired(required = false) - private HostRequestsQuotaService quotaService; - - - @Value("${coap.bind_address}") - private String host; - @Value("${coap.bind_port}") - private Integer port; - @Value("${coap.adaptor}") - private String adaptorName; - @Value("${coap.timeout}") - private Long timeout; - - private CoapTransportAdaptor adaptor; + private CoapServer server; @PostConstruct public void init() throws UnknownHostException { log.info("Starting CoAP transport..."); - log.info("Lookup CoAP transport adaptor {}", adaptorName); - this.adaptor = (CoapTransportAdaptor) appContext.getBean(adaptorName); log.info("Starting CoAP transport server"); this.server = new CoapServer(); createResources(); - InetAddress addr = InetAddress.getByName(host); - InetSocketAddress sockAddr = new InetSocketAddress(addr, port); + InetAddress addr = InetAddress.getByName(coapTransportContext.getHost()); + InetSocketAddress sockAddr = new InetSocketAddress(addr, coapTransportContext.getPort()); server.addEndpoint(new CoapEndpoint(sockAddr)); server.start(); log.info("CoAP transport started!"); @@ -87,7 +63,7 @@ public class CoapTransportService { private void createResources() { CoapResource api = new CoapResource(API); - api.add(new CoapTransportResource(processor, authService, adaptor, V1, timeout, quotaService)); + api.add(new CoapTransportResource(coapTransportContext, V1)); server.add(api); } diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java new file mode 100644 index 0000000000..9e48cd3030 --- /dev/null +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.coap.adaptors; + +import org.eclipse.californium.core.coap.Request; +import org.eclipse.californium.core.coap.Response; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.transport.coap.CoapTransportResource; + +import java.util.UUID; +import java.util.Optional; + +public interface CoapTransportAdaptor { + + TransportProtos.PostTelemetryMsg convertToPostTelemetry(UUID sessionId, Request inbound) throws AdaptorException; + + TransportProtos.PostAttributeMsg convertToPostAttributes(UUID sessionId, Request inbound) throws AdaptorException; + + TransportProtos.GetAttributeRequestMsg convertToGetAttributes(UUID sessionId, Request inbound) throws AdaptorException; + + TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(UUID sessionId, Request inbound) throws AdaptorException; + + TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(UUID sessionId, Request inbound) throws AdaptorException; + + Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException; + + Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; + + Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException; + + Response convertToPublish(CoapTransportResource.CoapSessionListener coapSessionListener, TransportProtos.ToServerRpcResponseMsg msg) throws AdaptorException; + +} diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java new file mode 100644 index 0000000000..f5bfb49c08 --- /dev/null +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java @@ -0,0 +1,155 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.coap.adaptors; + +import java.util.*; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.coap.CoAP; +import org.eclipse.californium.core.coap.Request; +import org.eclipse.californium.core.coap.Response; +import org.springframework.util.StringUtils; +import org.thingsboard.server.common.msg.kv.AttributesKVMsg; +import org.thingsboard.server.common.msg.session.SessionContext; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.springframework.stereotype.Component; + +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.transport.coap.CoapTransportResource; + +@Component("JsonCoapAdaptor") +@Slf4j +public class JsonCoapAdaptor implements CoapTransportAdaptor { + + @Override + public TransportProtos.PostTelemetryMsg convertToPostTelemetry(UUID sessionId, Request inbound) throws AdaptorException { + String payload = validatePayload(sessionId, inbound); + try { + return JsonConverter.convertToTelemetryProto(new JsonParser().parse(payload)); + } catch (IllegalStateException | JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + + @Override + public TransportProtos.PostAttributeMsg convertToPostAttributes(UUID sessionId, Request inbound) throws AdaptorException { + String payload = validatePayload(sessionId, inbound); + try { + return JsonConverter.convertToAttributesProto(new JsonParser().parse(payload)); + } catch (IllegalStateException | JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + + @Override + public TransportProtos.GetAttributeRequestMsg convertToGetAttributes(UUID sessionId, Request inbound) throws AdaptorException { + List queryElements = inbound.getOptions().getUriQuery(); + TransportProtos.GetAttributeRequestMsg.Builder result = TransportProtos.GetAttributeRequestMsg.newBuilder(); + if (queryElements != null && queryElements.size() > 0) { + Set clientKeys = toKeys(queryElements, "clientKeys"); + Set sharedKeys = toKeys(queryElements, "sharedKeys"); + if (clientKeys != null) { + result.addAllClientAttributeNames(clientKeys); + } + if (sharedKeys != null) { + result.addAllSharedAttributeNames(sharedKeys); + } + } + return result.build(); + } + + @Override + public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(UUID sessionId, Request inbound) throws AdaptorException { + Optional requestId = CoapTransportResource.getRequestId(inbound); + String payload = validatePayload(sessionId, inbound); + JsonObject response = new JsonParser().parse(payload).getAsJsonObject(); + return TransportProtos.ToDeviceRpcResponseMsg.newBuilder().setRequestId(requestId.orElseThrow(() -> new AdaptorException("Request id is missing!"))) + .setPayload(response.toString()).build(); + } + + @Override + public TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(UUID sessionId, Request inbound) throws AdaptorException { + String payload = validatePayload(sessionId, inbound); + return JsonConverter.convertToServerRpcRequest(new JsonParser().parse(payload), 0); + } + + @Override + public Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.AttributeUpdateNotificationMsg msg) throws AdaptorException { + return getObserveNotification(session.getNextSeqNumber(), JsonConverter.toJson(msg)); + } + + @Override + public Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.ToDeviceRpcRequestMsg msg) throws AdaptorException { + return getObserveNotification(session.getNextSeqNumber(), JsonConverter.toJson(msg, true)); + } + + @Override + public Response convertToPublish(CoapTransportResource.CoapSessionListener coapSessionListener, TransportProtos.ToServerRpcResponseMsg msg) throws AdaptorException { + Response response = new Response(CoAP.ResponseCode.CONTENT); + JsonElement result = JsonConverter.toJson(msg); + response.setPayload(result.toString()); + return response; + } + + @Override + public Response convertToPublish(CoapTransportResource.CoapSessionListener session, TransportProtos.GetAttributeResponseMsg msg) throws AdaptorException { + if (msg.getClientAttributeListCount() == 0 && msg.getSharedAttributeListCount() == 0 && msg.getDeletedAttributeKeysCount() == 0) { + return new Response(CoAP.ResponseCode.NOT_FOUND); + } else { + Response response = new Response(CoAP.ResponseCode.CONTENT); + JsonObject result = JsonConverter.toJson(msg); + response.setPayload(result.toString()); + return response; + } + } + + private Response getObserveNotification(int seqNumber, JsonElement json) { + Response response = new Response(CoAP.ResponseCode.CONTENT); + response.getOptions().setObserve(seqNumber); + response.setPayload(json.toString()); + return response; + } + + private String validatePayload(UUID sessionId, Request inbound) throws AdaptorException { + String payload = inbound.getPayloadString(); + if (payload == null) { + log.warn("[{}] Payload is empty!", sessionId); + throw new AdaptorException(new IllegalArgumentException("Payload is empty!")); + } + return payload; + } + + private Set toKeys(List queryElements, String attributeName) throws AdaptorException { + String keys = null; + for (String queryElement : queryElements) { + String[] queryItem = queryElement.split("="); + if (queryItem.length == 2 && queryItem[0].equals(attributeName)) { + keys = queryItem[1]; + } + } + if (keys != null && !StringUtils.isEmpty(keys)) { + return new HashSet<>(Arrays.asList(keys.split(","))); + } else { + return null; + } + } + +} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DeviceEmulator.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DeviceEmulator.java similarity index 100% rename from transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DeviceEmulator.java rename to common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/client/DeviceEmulator.java diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml new file mode 100644 index 0000000000..5d735c9def --- /dev/null +++ b/common/transport/http/pom.xml @@ -0,0 +1,81 @@ + + + 4.0.0 + + org.thingsboard.common + 2.2.0-SNAPSHOT + transport + + org.thingsboard.common.transport + http + jar + + Thingsboard HTTP Transport Common + https://thingsboard.io + + + UTF-8 + ${basedir}/../../.. + + + + + org.thingsboard.common.transport + transport-api + + + org.springframework.boot + spring-boot-starter-web + provided + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + org.springframework.boot + spring-boot-starter-test + test + + + junit + junit + test + + + org.mockito + mockito-all + test + + + + diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java new file mode 100644 index 0000000000..18458d13ad --- /dev/null +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -0,0 +1,295 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.http; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.context.request.async.DeferredResult; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.common.transport.TransportContext; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionCloseNotificationProto; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToRPCMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; + +import javax.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +/** + * @author Andrew Shvayka + */ +@RestController +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.http.enabled}'=='true')") +@RequestMapping("/api/v1") +@Slf4j +public class DeviceApiController { + + @Autowired + private HttpTransportContext transportContext; + + @RequestMapping(value = "/{deviceToken}/attributes", method = RequestMethod.GET, produces = "application/json") + public DeferredResult getDeviceAttributes(@PathVariable("deviceToken") String deviceToken, + @RequestParam(value = "clientKeys", required = false, defaultValue = "") String clientKeys, + @RequestParam(value = "sharedKeys", required = false, defaultValue = "") String sharedKeys, + HttpServletRequest httpRequest) { + DeferredResult responseWriter = new DeferredResult<>(); + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + GetAttributeRequestMsg.Builder request = GetAttributeRequestMsg.newBuilder().setRequestId(0); + List clientKeySet = !StringUtils.isEmpty(clientKeys) ? Arrays.asList(clientKeys.split(",")) : null; + List sharedKeySet = !StringUtils.isEmpty(sharedKeys) ? Arrays.asList(sharedKeys.split(",")) : null; + if (clientKeySet != null) { + request.addAllClientAttributeNames(clientKeySet); + } + if (sharedKeySet != null) { + request.addAllSharedAttributeNames(sharedKeySet); + } + TransportService transportService = transportContext.getTransportService(); + transportService.registerSyncSession(sessionInfo, new HttpSessionListener(responseWriter), transportContext.getDefaultTimeout()); + transportService.process(sessionInfo, request.build(), new SessionCloseOnErrorCallback(transportService, sessionInfo)); + })); + return responseWriter; + } + + @RequestMapping(value = "/{deviceToken}/attributes", method = RequestMethod.POST) + public DeferredResult postDeviceAttributes(@PathVariable("deviceToken") String deviceToken, + @RequestBody String json, HttpServletRequest request) { + DeferredResult responseWriter = new DeferredResult<>(); + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + TransportService transportService = transportContext.getTransportService(); + transportService.process(sessionInfo, JsonConverter.convertToAttributesProto(new JsonParser().parse(json)), + new HttpOkCallback(responseWriter)); + })); + return responseWriter; + } + + @RequestMapping(value = "/{deviceToken}/telemetry", method = RequestMethod.POST) + public DeferredResult postTelemetry(@PathVariable("deviceToken") String deviceToken, + @RequestBody String json, HttpServletRequest request) { + DeferredResult responseWriter = new DeferredResult(); + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + TransportService transportService = transportContext.getTransportService(); + transportService.process(sessionInfo, JsonConverter.convertToTelemetryProto(new JsonParser().parse(json)), + new HttpOkCallback(responseWriter)); + })); + return responseWriter; + } + + @RequestMapping(value = "/{deviceToken}/rpc", method = RequestMethod.GET, produces = "application/json") + public DeferredResult subscribeToCommands(@PathVariable("deviceToken") String deviceToken, + @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout, + HttpServletRequest httpRequest) { + DeferredResult responseWriter = new DeferredResult<>(); + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + TransportService transportService = transportContext.getTransportService(); + transportService.registerSyncSession(sessionInfo, new HttpSessionListener(responseWriter), + timeout == 0 ? transportContext.getDefaultTimeout() : timeout); + transportService.process(sessionInfo, SubscribeToRPCMsg.getDefaultInstance(), + new SessionCloseOnErrorCallback(transportService, sessionInfo)); + + })); + return responseWriter; + } + + @RequestMapping(value = "/{deviceToken}/rpc/{requestId}", method = RequestMethod.POST) + public DeferredResult replyToCommand(@PathVariable("deviceToken") String deviceToken, + @PathVariable("requestId") Integer requestId, + @RequestBody String json, HttpServletRequest request) { + DeferredResult responseWriter = new DeferredResult(); + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + TransportService transportService = transportContext.getTransportService(); + transportService.process(sessionInfo, ToDeviceRpcResponseMsg.newBuilder().setRequestId(requestId).setPayload(json).build(), new HttpOkCallback(responseWriter)); + })); + return responseWriter; + } + + @RequestMapping(value = "/{deviceToken}/rpc", method = RequestMethod.POST) + public DeferredResult postRpcRequest(@PathVariable("deviceToken") String deviceToken, + @RequestBody String json, HttpServletRequest httpRequest) { + DeferredResult responseWriter = new DeferredResult(); + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + JsonObject request = new JsonParser().parse(json).getAsJsonObject(); + TransportService transportService = transportContext.getTransportService(); + transportService.registerSyncSession(sessionInfo, new HttpSessionListener(responseWriter), transportContext.getDefaultTimeout()); + transportService.process(sessionInfo, ToServerRpcRequestMsg.newBuilder().setRequestId(0) + .setMethodName(request.get("method").getAsString()) + .setParams(request.get("params").toString()).build(), + new SessionCloseOnErrorCallback(transportService, sessionInfo)); + })); + return responseWriter; + } + + @RequestMapping(value = "/{deviceToken}/attributes/updates", method = RequestMethod.GET, produces = "application/json") + public DeferredResult subscribeToAttributes(@PathVariable("deviceToken") String deviceToken, + @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout, + HttpServletRequest httpRequest) { + DeferredResult responseWriter = new DeferredResult<>(); + transportContext.getTransportService().process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(deviceToken).build(), + new DeviceAuthCallback(transportContext, responseWriter, sessionInfo -> { + TransportService transportService = transportContext.getTransportService(); + transportService.registerSyncSession(sessionInfo, new HttpSessionListener(responseWriter), + timeout == 0 ? transportContext.getDefaultTimeout() : timeout); + transportService.process(sessionInfo, SubscribeToAttributeUpdatesMsg.getDefaultInstance(), + new SessionCloseOnErrorCallback(transportService, sessionInfo)); + + })); + return responseWriter; + } + + private static class DeviceAuthCallback implements TransportServiceCallback { + private final TransportContext transportContext; + private final DeferredResult responseWriter; + private final Consumer onSuccess; + + DeviceAuthCallback(TransportContext transportContext, DeferredResult responseWriter, Consumer onSuccess) { + this.transportContext = transportContext; + this.responseWriter = responseWriter; + this.onSuccess = onSuccess; + } + + @Override + public void onSuccess(ValidateDeviceCredentialsResponseMsg msg) { + if (msg.hasDeviceInfo()) { + UUID sessionId = UUID.randomUUID(); + DeviceInfoProto deviceInfoProto = msg.getDeviceInfo(); + SessionInfoProto sessionInfo = SessionInfoProto.newBuilder() + .setNodeId(transportContext.getNodeId()) + .setTenantIdMSB(deviceInfoProto.getTenantIdMSB()) + .setTenantIdLSB(deviceInfoProto.getTenantIdLSB()) + .setDeviceIdMSB(deviceInfoProto.getDeviceIdMSB()) + .setDeviceIdLSB(deviceInfoProto.getDeviceIdLSB()) + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .build(); + onSuccess.accept(sessionInfo); + } else { + responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); + } + } + + @Override + public void onError(Throwable e) { + log.warn("Failed to process request", e); + responseWriter.setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + } + + private static class SessionCloseOnErrorCallback implements TransportServiceCallback { + private final TransportService transportService; + private final SessionInfoProto sessionInfo; + + SessionCloseOnErrorCallback(TransportService transportService, SessionInfoProto sessionInfo) { + this.transportService = transportService; + this.sessionInfo = sessionInfo; + } + + @Override + public void onSuccess(Void msg) { + } + + @Override + public void onError(Throwable e) { + transportService.deregisterSession(sessionInfo); + } + } + + private static class HttpOkCallback implements TransportServiceCallback { + private final DeferredResult responseWriter; + + public HttpOkCallback(DeferredResult responseWriter) { + this.responseWriter = responseWriter; + } + + @Override + public void onSuccess(Void msg) { + responseWriter.setResult(new ResponseEntity<>(HttpStatus.OK)); + } + + @Override + public void onError(Throwable e) { + responseWriter.setResult(new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR)); + } + } + + + private static class HttpSessionListener implements SessionMsgListener { + + private final DeferredResult responseWriter; + + HttpSessionListener(DeferredResult responseWriter) { + this.responseWriter = responseWriter; + } + + @Override + public void onGetAttributesResponse(GetAttributeResponseMsg msg) { + responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); + } + + @Override + public void onAttributeUpdate(AttributeUpdateNotificationMsg msg) { + responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); + } + + @Override + public void onRemoteSessionCloseCommand(SessionCloseNotificationProto sessionCloseNotification) { + responseWriter.setResult(new ResponseEntity<>(HttpStatus.REQUEST_TIMEOUT)); + } + + @Override + public void onToDeviceRpcRequest(ToDeviceRpcRequestMsg msg) { + responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg, true).toString(), HttpStatus.OK)); + } + + @Override + public void onToServerRpcResponse(ToServerRpcResponseMsg msg) { + responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); + } + } +} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java similarity index 52% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java rename to common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java index 9b3b4614e4..601c9ee8e8 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestIntervalRegistry.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java @@ -13,25 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.transport.quota.host; +package org.thingsboard.server.transport.http; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.KeyBasedIntervalRegistry; +import org.thingsboard.server.common.transport.TransportContext; + +import javax.annotation.PostConstruct; /** - * @author Vitaliy Paromskiy - * @version 1.0 + * Created by ashvayka on 04.10.18. */ -@Component @Slf4j -public class HostRequestIntervalRegistry extends KeyBasedIntervalRegistry { +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.http.enabled}'=='true')") +@Component +public class HttpTransportContext extends TransportContext { + + @Getter + @Value("${transport.http.request_timeout}") + private long defaultTimeout; - public HostRequestIntervalRegistry(@Value("${quota.host.intervalMs}") long intervalDurationMs, - @Value("${quota.host.ttlMs}") long ttlMs, - @Value("${quota.host.whitelist}") String whiteList, - @Value("${quota.host.blacklist}") String blackList) { - super(intervalDurationMs, ttlMs, whiteList, blackList, "host"); - } } diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml new file mode 100644 index 0000000000..91f30a6f3d --- /dev/null +++ b/common/transport/mqtt/pom.xml @@ -0,0 +1,98 @@ + + + 4.0.0 + + org.thingsboard.common + 2.2.0-SNAPSHOT + transport + + org.thingsboard.common.transport + mqtt + jar + + Thingsboard MQTT Transport Common + https://thingsboard.io + + + UTF-8 + ${basedir}/../../.. + + + + + org.thingsboard.common.transport + transport-api + + + io.netty + netty-all + + + org.springframework + spring-context-support + + + org.springframework + spring-context + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + com.google.guava + guava + + + com.google.code.findbugs + jsr305 + 3.0.1 + true + + + org.springframework.boot + spring-boot-starter-test + test + + + junit + junit + test + + + org.mockito + mockito-all + test + + + + diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java similarity index 69% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 9c458fd39f..aa643ebc9b 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -20,15 +20,23 @@ import io.netty.handler.ssl.SslHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import org.thingsboard.server.common.data.security.DeviceCredentials; -import org.thingsboard.server.dao.EncryptionUtil; -import org.thingsboard.server.dao.device.DeviceCredentialsService; +import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.mqtt.util.SslUtil; -import javax.net.ssl.*; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; import java.io.File; import java.io.FileInputStream; import java.io.IOException; @@ -37,12 +45,15 @@ import java.net.URL; import java.security.KeyStore; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; /** * Created by valerii.sosliuk on 11/6/16. */ @Slf4j @Component("MqttSslHandlerProvider") +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.http.enabled}'=='true')") @ConditionalOnProperty(prefix = "mqtt.ssl", value = "enabled", havingValue = "true", matchIfMissing = false) public class MqttSslHandlerProvider { @@ -58,8 +69,7 @@ public class MqttSslHandlerProvider { private String keyStoreType; @Autowired - private DeviceCredentialsService deviceCredentialsService; - + private TransportService transportService; public SslHandler getSslHandler() { try { @@ -112,17 +122,17 @@ public class MqttSslHandlerProvider { break; } } - return new ThingsboardMqttX509TrustManager(x509Tm, deviceCredentialsService); + return new ThingsboardMqttX509TrustManager(x509Tm, transportService); } static class ThingsboardMqttX509TrustManager implements X509TrustManager { private final X509TrustManager trustManager; - private DeviceCredentialsService deviceCredentialsService; + private TransportService transportService; - ThingsboardMqttX509TrustManager(X509TrustManager trustManager, DeviceCredentialsService deviceCredentialsService) { + ThingsboardMqttX509TrustManager(X509TrustManager trustManager, TransportService transportService) { this.trustManager = trustManager; - this.deviceCredentialsService = deviceCredentialsService; + this.transportService = transportService; } @Override @@ -139,20 +149,39 @@ public class MqttSslHandlerProvider { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { - DeviceCredentials deviceCredentials = null; + String credentialsBody = null; for (X509Certificate cert : chain) { try { String strCert = SslUtil.getX509CertificateString(cert); String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - deviceCredentials = deviceCredentialsService.findDeviceCredentialsByCredentialsId(sha3Hash); - if (deviceCredentials != null && strCert.equals(deviceCredentials.getCredentialsValue())) { + final String[] credentialsBodyHolder = new String[1]; + CountDownLatch latch = new CountDownLatch(1); + transportService.process(TransportProtos.ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), + new TransportServiceCallback() { + @Override + public void onSuccess(TransportProtos.ValidateDeviceCredentialsResponseMsg msg) { + if (!StringUtils.isEmpty(msg.getCredentialsBody())) { + credentialsBodyHolder[0] = msg.getCredentialsBody(); + } + latch.countDown(); + } + + @Override + public void onError(Throwable e) { + log.error(e.getMessage(), e); + latch.countDown(); + } + }); + latch.await(10, TimeUnit.SECONDS); + if (strCert.equals(credentialsBodyHolder[0])) { + credentialsBody = credentialsBodyHolder[0]; break; } - } catch (IOException e) { + } catch (InterruptedException | IOException e) { log.error(e.getMessage(), e); } } - if (deviceCredentials == null) { + if (credentialsBody == null) { throw new CertificateException("Invalid Device Certificate"); } } diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java similarity index 100% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTopics.java diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java new file mode 100644 index 0000000000..184ac49c25 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.netty.handler.ssl.SslHandler; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.transport.TransportContext; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.kafka.TbNodeIdProvider; +import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Created by ashvayka on 04.10.18. + */ +@Slf4j +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.mqtt.enabled}'=='true')") +@Component +public class MqttTransportContext extends TransportContext { + + @Getter + @Autowired(required = false) + private MqttSslHandlerProvider sslHandlerProvider; + + @Getter + @Autowired + private MqttTransportAdaptor adaptor; + + @Getter + @Value("${transport.mqtt.netty.max_payload_size}") + private Integer maxPayloadSize; + + @Getter + @Setter + private SslHandler sslHandler; + +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java new file mode 100644 index 0000000000..a178bdf891 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -0,0 +1,545 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import com.fasterxml.jackson.databind.JsonNode; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.ChannelInboundHandlerAdapter; +import io.netty.handler.codec.mqtt.MqttConnAckMessage; +import io.netty.handler.codec.mqtt.MqttConnAckVariableHeader; +import io.netty.handler.codec.mqtt.MqttConnectMessage; +import io.netty.handler.codec.mqtt.MqttConnectReturnCode; +import io.netty.handler.codec.mqtt.MqttFixedHeader; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttMessageIdVariableHeader; +import io.netty.handler.codec.mqtt.MqttPubAckMessage; +import io.netty.handler.codec.mqtt.MqttPublishMessage; +import io.netty.handler.codec.mqtt.MqttQoS; +import io.netty.handler.codec.mqtt.MqttSubAckMessage; +import io.netty.handler.codec.mqtt.MqttSubAckPayload; +import io.netty.handler.codec.mqtt.MqttSubscribeMessage; +import io.netty.handler.codec.mqtt.MqttTopicSubscription; +import io.netty.handler.codec.mqtt.MqttUnsubscribeMessage; +import io.netty.handler.ssl.SslHandler; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.GenericFutureListener; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.common.msg.EncryptionUtil; +import org.thingsboard.server.common.transport.service.AbstractTransportService; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEvent; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; +import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; +import org.thingsboard.server.transport.mqtt.session.GatewaySessionHandler; +import org.thingsboard.server.transport.mqtt.session.MqttTopicMatcher; +import org.thingsboard.server.transport.mqtt.util.SslUtil; + +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.security.cert.X509Certificate; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_ACCEPTED; +import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD; +import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.CONNECTION_REFUSED_NOT_AUTHORIZED; +import static io.netty.handler.codec.mqtt.MqttMessageType.CONNACK; +import static io.netty.handler.codec.mqtt.MqttMessageType.PINGRESP; +import static io.netty.handler.codec.mqtt.MqttMessageType.PUBACK; +import static io.netty.handler.codec.mqtt.MqttMessageType.SUBACK; +import static io.netty.handler.codec.mqtt.MqttMessageType.UNSUBACK; +import static io.netty.handler.codec.mqtt.MqttQoS.AT_LEAST_ONCE; +import static io.netty.handler.codec.mqtt.MqttQoS.AT_MOST_ONCE; +import static io.netty.handler.codec.mqtt.MqttQoS.FAILURE; + +/** + * @author Andrew Shvayka + */ +@Slf4j +public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener>, SessionMsgListener { + + private static final MqttQoS MAX_SUPPORTED_QOS_LVL = AT_LEAST_ONCE; + + private final UUID sessionId; + private final MqttTransportContext context; + private final MqttTransportAdaptor adaptor; + private final TransportService transportService; + private final SslHandler sslHandler; + private final ConcurrentMap mqttQoSMap; + + private volatile SessionInfoProto sessionInfo; + private volatile InetSocketAddress address; + private volatile DeviceSessionCtx deviceSessionCtx; + private volatile GatewaySessionHandler gatewaySessionHandler; + + MqttTransportHandler(MqttTransportContext context) { + this.sessionId = UUID.randomUUID(); + this.context = context; + this.transportService = context.getTransportService(); + this.adaptor = context.getAdaptor(); + this.sslHandler = context.getSslHandler(); + this.mqttQoSMap = new ConcurrentHashMap<>(); + this.deviceSessionCtx = new DeviceSessionCtx(sessionId, mqttQoSMap); + } + + @Override + public void channelRead(ChannelHandlerContext ctx, Object msg) { + log.trace("[{}] Processing msg: {}", sessionId, msg); + if (msg instanceof MqttMessage) { + processMqttMsg(ctx, (MqttMessage) msg); + } else { + ctx.close(); + } + } + + private void processMqttMsg(ChannelHandlerContext ctx, MqttMessage msg) { + address = (InetSocketAddress) ctx.channel().remoteAddress(); + if (msg.fixedHeader() == null) { + log.info("[{}:{}] Invalid message received", address.getHostName(), address.getPort()); + processDisconnect(ctx); + return; + } + deviceSessionCtx.setChannel(ctx); + switch (msg.fixedHeader().messageType()) { + case CONNECT: + processConnect(ctx, (MqttConnectMessage) msg); + break; + case PUBLISH: + processPublish(ctx, (MqttPublishMessage) msg); + break; + case SUBSCRIBE: + processSubscribe(ctx, (MqttSubscribeMessage) msg); + break; + case UNSUBSCRIBE: + processUnsubscribe(ctx, (MqttUnsubscribeMessage) msg); + break; + case PINGREQ: + if (checkConnected(ctx)) { + ctx.writeAndFlush(new MqttMessage(new MqttFixedHeader(PINGRESP, false, AT_MOST_ONCE, false, 0))); + transportService.reportActivity(sessionInfo); + if (gatewaySessionHandler != null) { + gatewaySessionHandler.reportActivity(); + } + } + break; + case DISCONNECT: + if (checkConnected(ctx)) { + processDisconnect(ctx); + } + break; + default: + break; + } + + } + + private void processPublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg) { + if (!checkConnected(ctx)) { + return; + } + String topicName = mqttMsg.variableHeader().topicName(); + int msgId = mqttMsg.variableHeader().packetId(); + log.trace("[{}] Processing publish msg [{}][{}]!", sessionId, topicName, msgId); + + if (topicName.startsWith(MqttTopics.BASE_GATEWAY_API_TOPIC)) { + if (gatewaySessionHandler != null) { + handleGatewayPublishMsg(topicName, msgId, mqttMsg); + } + } else { + processDevicePublish(ctx, mqttMsg, topicName, msgId); + } + } + + private void handleGatewayPublishMsg(String topicName, int msgId, MqttPublishMessage mqttMsg) { + try { + switch (topicName) { + case MqttTopics.GATEWAY_TELEMETRY_TOPIC: + gatewaySessionHandler.onDeviceTelemetry(mqttMsg); + break; + case MqttTopics.GATEWAY_ATTRIBUTES_TOPIC: + gatewaySessionHandler.onDeviceAttributes(mqttMsg); + break; + case MqttTopics.GATEWAY_ATTRIBUTES_REQUEST_TOPIC: + gatewaySessionHandler.onDeviceAttributesRequest(mqttMsg); + break; + case MqttTopics.GATEWAY_RPC_TOPIC: + gatewaySessionHandler.onDeviceRpcResponse(mqttMsg); + break; + case MqttTopics.GATEWAY_CONNECT_TOPIC: + gatewaySessionHandler.onDeviceConnect(mqttMsg); + break; + case MqttTopics.GATEWAY_DISCONNECT_TOPIC: + gatewaySessionHandler.onDeviceDisconnect(mqttMsg); + break; + } + } catch (RuntimeException | AdaptorException e) { + log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); + } + } + + private void processDevicePublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg, String topicName, int msgId) { + try { + if (topicName.equals(MqttTopics.DEVICE_TELEMETRY_TOPIC)) { + TransportProtos.PostTelemetryMsg postTelemetryMsg = adaptor.convertToPostTelemetry(deviceSessionCtx, mqttMsg); + transportService.process(sessionInfo, postTelemetryMsg, getPubAckCallback(ctx, msgId, postTelemetryMsg)); + } else if (topicName.equals(MqttTopics.DEVICE_ATTRIBUTES_TOPIC)) { + TransportProtos.PostAttributeMsg postAttributeMsg = adaptor.convertToPostAttributes(deviceSessionCtx, mqttMsg); + transportService.process(sessionInfo, postAttributeMsg, getPubAckCallback(ctx, msgId, postAttributeMsg)); + } else if (topicName.startsWith(MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX)) { + TransportProtos.GetAttributeRequestMsg getAttributeMsg = adaptor.convertToGetAttributes(deviceSessionCtx, mqttMsg); + transportService.process(sessionInfo, getAttributeMsg, getPubAckCallback(ctx, msgId, getAttributeMsg)); + } else if (topicName.startsWith(MqttTopics.DEVICE_RPC_RESPONSE_TOPIC)) { + TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg = adaptor.convertToDeviceRpcResponse(deviceSessionCtx, mqttMsg); + transportService.process(sessionInfo, rpcResponseMsg, getPubAckCallback(ctx, msgId, rpcResponseMsg)); + } else if (topicName.startsWith(MqttTopics.DEVICE_RPC_REQUESTS_TOPIC)) { + TransportProtos.ToServerRpcRequestMsg rpcRequestMsg = adaptor.convertToServerRpcRequest(deviceSessionCtx, mqttMsg); + transportService.process(sessionInfo, rpcRequestMsg, getPubAckCallback(ctx, msgId, rpcRequestMsg)); + } + } catch (AdaptorException e) { + log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); + log.info("[{}] Closing current session due to invalid publish msg [{}][{}]", sessionId, topicName, msgId); + ctx.close(); + } + } + + private TransportServiceCallback getPubAckCallback(final ChannelHandlerContext ctx, final int msgId, final T msg) { + return new TransportServiceCallback() { + @Override + public void onSuccess(Void dummy) { + log.trace("[{}] Published msg: {}", sessionId, msg); + if (msgId > 0) { + ctx.writeAndFlush(createMqttPubAckMsg(msgId)); + } + } + + @Override + public void onError(Throwable e) { + log.trace("[{}] Failed to publish msg: {}", sessionId, msg, e); + processDisconnect(ctx); + } + }; + } + + private void processSubscribe(ChannelHandlerContext ctx, MqttSubscribeMessage mqttMsg) { + if (!checkConnected(ctx)) { + return; + } + log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); + List grantedQoSList = new ArrayList<>(); + for (MqttTopicSubscription subscription : mqttMsg.payload().topicSubscriptions()) { + String topic = subscription.topicName(); + MqttQoS reqQoS = subscription.qualityOfService(); + try { + switch (topic) { + case MqttTopics.DEVICE_ATTRIBUTES_TOPIC: { + transportService.process(sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.newBuilder().build(), null); + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + } + case MqttTopics.DEVICE_RPC_REQUESTS_SUB_TOPIC: { + transportService.process(sessionInfo, TransportProtos.SubscribeToRPCMsg.newBuilder().build(), null); + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + } + case MqttTopics.DEVICE_RPC_RESPONSE_SUB_TOPIC: + case MqttTopics.GATEWAY_ATTRIBUTES_TOPIC: + case MqttTopics.GATEWAY_RPC_TOPIC: + case MqttTopics.DEVICE_ATTRIBUTES_RESPONSES_TOPIC: + registerSubQoS(topic, grantedQoSList, reqQoS); + break; + default: + log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); + grantedQoSList.add(FAILURE.value()); + break; + } + } catch (Exception e) { + log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topic, reqQoS); + grantedQoSList.add(FAILURE.value()); + } + } + ctx.writeAndFlush(createSubAckMessage(mqttMsg.variableHeader().messageId(), grantedQoSList)); + } + + private void registerSubQoS(String topic, List grantedQoSList, MqttQoS reqQoS) { + grantedQoSList.add(getMinSupportedQos(reqQoS)); + mqttQoSMap.put(new MqttTopicMatcher(topic), getMinSupportedQos(reqQoS)); + } + + private void processUnsubscribe(ChannelHandlerContext ctx, MqttUnsubscribeMessage mqttMsg) { + if (!checkConnected(ctx)) { + return; + } + log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); + for (String topicName : mqttMsg.payload().topics()) { + mqttQoSMap.remove(new MqttTopicMatcher(topicName)); + try { + switch (topicName) { + case MqttTopics.DEVICE_ATTRIBUTES_TOPIC: { + transportService.process(sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.newBuilder().setUnsubscribe(true).build(), null); + break; + } + case MqttTopics.DEVICE_RPC_REQUESTS_SUB_TOPIC: { + transportService.process(sessionInfo, TransportProtos.SubscribeToRPCMsg.newBuilder().setUnsubscribe(true).build(), null); + break; + } + } + } catch (Exception e) { + log.warn("[{}] Failed to process unsubscription [{}] to [{}]", sessionId, mqttMsg.variableHeader().messageId(), topicName); + } + } + ctx.writeAndFlush(createUnSubAckMessage(mqttMsg.variableHeader().messageId())); + } + + private MqttMessage createUnSubAckMessage(int msgId) { + MqttFixedHeader mqttFixedHeader = + new MqttFixedHeader(UNSUBACK, false, AT_LEAST_ONCE, false, 0); + MqttMessageIdVariableHeader mqttMessageIdVariableHeader = MqttMessageIdVariableHeader.from(msgId); + return new MqttMessage(mqttFixedHeader, mqttMessageIdVariableHeader); + } + + private void processConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) { + log.info("[{}] Processing connect msg for client: {}!", sessionId, msg.payload().clientIdentifier()); + X509Certificate cert; + if (sslHandler != null && (cert = getX509Certificate()) != null) { + processX509CertConnect(ctx, cert); + } else { + processAuthTokenConnect(ctx, msg); + } + } + + private void processAuthTokenConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) { + String userName = msg.payload().userName(); + if (StringUtils.isEmpty(userName)) { + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD)); + ctx.close(); + } else { + transportService.process(ValidateDeviceTokenRequestMsg.newBuilder().setToken(userName).build(), + new TransportServiceCallback() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponseMsg msg) { + onValidateDeviceResponse(msg, ctx); + } + + @Override + public void onError(Throwable e) { + log.trace("[{}] Failed to process credentials: {}", address, userName, e); + ctx.writeAndFlush(createMqttConnAckMsg(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE)); + ctx.close(); + } + }); + } + } + + private void processX509CertConnect(ChannelHandlerContext ctx, X509Certificate cert) { + try { + String strCert = SslUtil.getX509CertificateString(cert); + String sha3Hash = EncryptionUtil.getSha3Hash(strCert); + transportService.process(ValidateDeviceX509CertRequestMsg.newBuilder().setHash(sha3Hash).build(), + new TransportServiceCallback() { + @Override + public void onSuccess(ValidateDeviceCredentialsResponseMsg msg) { + onValidateDeviceResponse(msg, ctx); + } + + @Override + public void onError(Throwable e) { + log.trace("[{}] Failed to process credentials: {}", address, sha3Hash, e); + ctx.writeAndFlush(createMqttConnAckMsg(MqttConnectReturnCode.CONNECTION_REFUSED_SERVER_UNAVAILABLE)); + ctx.close(); + } + }); + } catch (Exception e) { + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); + ctx.close(); + } + } + + private X509Certificate getX509Certificate() { + try { + X509Certificate[] certChain = sslHandler.engine().getSession().getPeerCertificateChain(); + if (certChain.length > 0) { + return certChain[0]; + } + } catch (SSLPeerUnverifiedException e) { + log.warn(e.getMessage()); + return null; + } + return null; + } + + private void processDisconnect(ChannelHandlerContext ctx) { + ctx.close(); + if (deviceSessionCtx.isConnected()) { + transportService.process(sessionInfo, AbstractTransportService.getSessionEventMsg(SessionEvent.CLOSED), null); + transportService.deregisterSession(sessionInfo); + if (gatewaySessionHandler != null) { + gatewaySessionHandler.onGatewayDisconnect(); + } + } + } + + private MqttConnAckMessage createMqttConnAckMsg(MqttConnectReturnCode returnCode) { + MqttFixedHeader mqttFixedHeader = + new MqttFixedHeader(CONNACK, false, AT_MOST_ONCE, false, 0); + MqttConnAckVariableHeader mqttConnAckVariableHeader = + new MqttConnAckVariableHeader(returnCode, true); + return new MqttConnAckMessage(mqttFixedHeader, mqttConnAckVariableHeader); + } + + @Override + public void channelReadComplete(ChannelHandlerContext ctx) { + ctx.flush(); + } + + @Override + public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { + log.error("[{}] Unexpected Exception", sessionId, cause); + ctx.close(); + } + + private static MqttSubAckMessage createSubAckMessage(Integer msgId, List grantedQoSList) { + MqttFixedHeader mqttFixedHeader = + new MqttFixedHeader(SUBACK, false, AT_LEAST_ONCE, false, 0); + MqttMessageIdVariableHeader mqttMessageIdVariableHeader = MqttMessageIdVariableHeader.from(msgId); + MqttSubAckPayload mqttSubAckPayload = new MqttSubAckPayload(grantedQoSList); + return new MqttSubAckMessage(mqttFixedHeader, mqttMessageIdVariableHeader, mqttSubAckPayload); + } + + private static int getMinSupportedQos(MqttQoS reqQoS) { + return Math.min(reqQoS.value(), MAX_SUPPORTED_QOS_LVL.value()); + } + + public static MqttPubAckMessage createMqttPubAckMsg(int requestId) { + MqttFixedHeader mqttFixedHeader = + new MqttFixedHeader(PUBACK, false, AT_LEAST_ONCE, false, 0); + MqttMessageIdVariableHeader mqttMsgIdVariableHeader = + MqttMessageIdVariableHeader.from(requestId); + return new MqttPubAckMessage(mqttFixedHeader, mqttMsgIdVariableHeader); + } + + private boolean checkConnected(ChannelHandlerContext ctx) { + if (deviceSessionCtx.isConnected()) { + return true; + } else { + log.info("[{}] Closing current session due to invalid msg order [{}][{}]", sessionId); + ctx.close(); + return false; + } + } + + private void checkGatewaySession() { + DeviceInfoProto device = deviceSessionCtx.getDeviceInfo(); + try { + JsonNode infoNode = context.getMapper().readTree(device.getAdditionalInfo()); + if (infoNode != null) { + JsonNode gatewayNode = infoNode.get("gateway"); + if (gatewayNode != null && gatewayNode.asBoolean()) { + gatewaySessionHandler = new GatewaySessionHandler(context, deviceSessionCtx, sessionId); + } + } + } catch (IOException e) { + log.trace("[{}][{}] Failed to fetch device additional info", sessionId, device.getDeviceName(), e); + } + } + + @Override + public void operationComplete(Future future) throws Exception { + if (deviceSessionCtx.isConnected()) { + transportService.process(sessionInfo, AbstractTransportService.getSessionEventMsg(SessionEvent.CLOSED), null); + transportService.deregisterSession(sessionInfo); + } + } + + private void onValidateDeviceResponse(ValidateDeviceCredentialsResponseMsg msg, ChannelHandlerContext ctx) { + if (!msg.hasDeviceInfo()) { + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); + ctx.close(); + } else { + deviceSessionCtx.setDeviceInfo(msg.getDeviceInfo()); + sessionInfo = SessionInfoProto.newBuilder() + .setNodeId(context.getNodeId()) + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setDeviceIdMSB(msg.getDeviceInfo().getDeviceIdMSB()) + .setDeviceIdLSB(msg.getDeviceInfo().getDeviceIdLSB()) + .setTenantIdMSB(msg.getDeviceInfo().getTenantIdMSB()) + .setTenantIdLSB(msg.getDeviceInfo().getTenantIdLSB()) + .build(); + transportService.process(sessionInfo, AbstractTransportService.getSessionEventMsg(SessionEvent.OPEN), null); + transportService.registerAsyncSession(sessionInfo, this); + checkGatewaySession(); + ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); + } + } + + @Override + public void onGetAttributesResponse(TransportProtos.GetAttributeResponseMsg response) { + try { + adaptor.convertToPublish(deviceSessionCtx, response).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device attributes response to MQTT msg", sessionId, e); + } + } + + @Override + public void onAttributeUpdate(TransportProtos.AttributeUpdateNotificationMsg notification) { + try { + adaptor.convertToPublish(deviceSessionCtx, notification).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device attributes update to MQTT msg", sessionId, e); + } + } + + @Override + public void onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto sessionCloseNotification) { + log.trace("[{}] Received the remote command to close the session", sessionId); + processDisconnect(deviceSessionCtx.getChannel()); + } + + @Override + public void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg rpcRequest) { + log.trace("[{}] Received RPC command to device", sessionId); + try { + adaptor.convertToPublish(deviceSessionCtx, rpcRequest).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device RPC commandto MQTT msg", sessionId, e); + } + } + + @Override + public void onToServerRpcResponse(TransportProtos.ToServerRpcResponseMsg rpcResponse) { + log.trace("[{}] Received RPC command to device", sessionId); + try { + adaptor.convertToPublish(deviceSessionCtx, rpcResponse).ifPresent(deviceSessionCtx.getChannel()::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device RPC commandto MQTT msg", sessionId, e); + } + } +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java new file mode 100644 index 0000000000..550837842f --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java @@ -0,0 +1,54 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelPipeline; +import io.netty.channel.socket.SocketChannel; +import io.netty.handler.codec.mqtt.MqttDecoder; +import io.netty.handler.codec.mqtt.MqttEncoder; +import io.netty.handler.ssl.SslHandler; + +/** + * @author Andrew Shvayka + */ +public class MqttTransportServerInitializer extends ChannelInitializer { + + private final MqttTransportContext context; + + public MqttTransportServerInitializer(MqttTransportContext context) { + this.context = context; + } + + @Override + public void initChannel(SocketChannel ch) { + ChannelPipeline pipeline = ch.pipeline(); + SslHandler sslHandler = null; + if (context.getSslHandlerProvider() != null) { + sslHandler = context.getSslHandlerProvider().getSslHandler(); + pipeline.addLast(sslHandler); + context.setSslHandler(sslHandler); + } + pipeline.addLast("decoder", new MqttDecoder(context.getMaxPayloadSize())); + pipeline.addLast("encoder", MqttEncoder.INSTANCE); + + MqttTransportHandler handler = new MqttTransportHandler(context); + + pipeline.addLast(handler); + ch.closeFuture().addListener(handler); + } + +} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java similarity index 58% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index bb8d4ad54c..55df31a735 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -24,15 +24,9 @@ import io.netty.util.ResourceLeakDetector; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Service; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; import javax.annotation.PostConstruct; import javax.annotation.PreDestroy; @@ -41,51 +35,24 @@ import javax.annotation.PreDestroy; * @author Andrew Shvayka */ @Service("MqttTransportService") -@ConditionalOnProperty(prefix = "mqtt", value = "enabled", havingValue = "true", matchIfMissing = false) +@ConditionalOnExpression("'${transport.type:null}'=='null' || ('${transport.type}'=='local' && '${transport.mqtt.enabled}'=='true')") @Slf4j public class MqttTransportService { - private static final String V1 = "v1"; - private static final String DEVICE = "device"; - - @Autowired(required = false) - private ApplicationContext appContext; - - @Autowired(required = false) - private SessionMsgProcessor processor; - - @Autowired(required = false) - private DeviceService deviceService; - - @Autowired(required = false) - private DeviceAuthService authService; - - @Autowired(required = false) - private RelationService relationService; - - @Autowired(required = false) - private MqttSslHandlerProvider sslHandlerProvider; - - @Autowired(required = false) - private HostRequestsQuotaService quotaService; - - @Value("${mqtt.bind_address}") + @Value("${transport.mqtt.bind_address}") private String host; - @Value("${mqtt.bind_port}") + @Value("${transport.mqtt.bind_port}") private Integer port; - @Value("${mqtt.adaptor}") - private String adaptorName; - @Value("${mqtt.netty.leak_detector_level}") + @Value("${transport.mqtt.netty.leak_detector_level}") private String leakDetectorLevel; - @Value("${mqtt.netty.boss_group_thread_count}") + @Value("${transport.mqtt.netty.boss_group_thread_count}") private Integer bossGroupThreadCount; - @Value("${mqtt.netty.worker_group_thread_count}") + @Value("${transport.mqtt.netty.worker_group_thread_count}") private Integer workerGroupThreadCount; - @Value("${mqtt.netty.max_payload_size}") - private Integer maxPayloadSize; - private MqttTransportAdaptor adaptor; + @Autowired + private MqttTransportContext context; private Channel serverChannel; private EventLoopGroup bossGroup; @@ -97,17 +64,12 @@ public class MqttTransportService { ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.valueOf(leakDetectorLevel.toUpperCase())); log.info("Starting MQTT transport..."); - log.info("Lookup MQTT transport adaptor {}", adaptorName); - this.adaptor = (MqttTransportAdaptor) appContext.getBean(adaptorName); - - log.info("Starting MQTT transport server"); bossGroup = new NioEventLoopGroup(bossGroupThreadCount); workerGroup = new NioEventLoopGroup(workerGroupThreadCount); ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) - .childHandler(new MqttTransportServerInitializer(processor, deviceService, authService, relationService, - adaptor, sslHandlerProvider, quotaService, maxPayloadSize)); + .childHandler(new MqttTransportServerInitializer(context)); serverChannel = b.bind(host, port).sync().channel(); log.info("Mqtt transport started!"); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java new file mode 100644 index 0000000000..8393ca9e0c --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java @@ -0,0 +1,217 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.adaptors; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import io.netty.buffer.UnpooledByteBufAllocator; +import io.netty.handler.codec.mqtt.MqttFixedHeader; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttMessageType; +import io.netty.handler.codec.mqtt.MqttPublishMessage; +import io.netty.handler.codec.mqtt.MqttPublishVariableHeader; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.transport.mqtt.MqttTopics; +import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionContext; + +import java.nio.charset.Charset; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * @author Andrew Shvayka + */ +@Component("JsonMqttAdaptor") +@Slf4j +public class JsonMqttAdaptor implements MqttTransportAdaptor { + + private static final Gson GSON = new Gson(); + private static final Charset UTF8 = Charset.forName("UTF-8"); + private static final ByteBufAllocator ALLOCATOR = new UnpooledByteBufAllocator(false); + + @Override + public TransportProtos.PostTelemetryMsg convertToPostTelemetry(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { + String payload = validatePayload(ctx.getSessionId(), inbound.payload()); + try { + return JsonConverter.convertToTelemetryProto(new JsonParser().parse(payload)); + } catch (IllegalStateException | JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + + @Override + public TransportProtos.PostAttributeMsg convertToPostAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { + String payload = validatePayload(ctx.getSessionId(), inbound.payload()); + try { + return JsonConverter.convertToAttributesProto(new JsonParser().parse(payload)); + } catch (IllegalStateException | JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + + @Override + public TransportProtos.GetAttributeRequestMsg convertToGetAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { + String topicName = inbound.variableHeader().topicName(); + try { + TransportProtos.GetAttributeRequestMsg.Builder result = TransportProtos.GetAttributeRequestMsg.newBuilder(); + result.setRequestId(Integer.valueOf(topicName.substring(MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX.length()))); + String payload = inbound.payload().toString(UTF8); + JsonElement requestBody = new JsonParser().parse(payload); + Set clientKeys = toStringSet(requestBody, "clientKeys"); + Set sharedKeys = toStringSet(requestBody, "sharedKeys"); + if (clientKeys != null) { + result.addAllClientAttributeNames(clientKeys); + } + if (sharedKeys != null) { + result.addAllSharedAttributeNames(sharedKeys); + } + return result.build(); + } catch (RuntimeException e) { + log.warn("Failed to decode get attributes request", e); + throw new AdaptorException(e); + } + } + + @Override + public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { + String topicName = inbound.variableHeader().topicName(); + try { + Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_RPC_RESPONSE_TOPIC.length())); + String payload = inbound.payload().toString(UTF8); + return TransportProtos.ToDeviceRpcResponseMsg.newBuilder().setRequestId(requestId).setPayload(payload).build(); + } catch (RuntimeException e) { + log.warn("Failed to decode get attributes request", e); + throw new AdaptorException(e); + } + } + + @Override + public TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { + String topicName = inbound.variableHeader().topicName(); + String payload = validatePayload(ctx.getSessionId(), inbound.payload()); + try { + Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_RPC_REQUESTS_TOPIC.length())); + return JsonConverter.convertToServerRpcRequest(new JsonParser().parse(payload), requestId); + } catch (IllegalStateException | JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + + @Override + public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException { + if (!StringUtils.isEmpty(responseMsg.getError())) { + throw new AdaptorException(responseMsg.getError()); + } else { + Integer requestId = responseMsg.getRequestId(); + if (requestId >= 0) { + return Optional.of(createMqttPublishMsg(ctx, + MqttTopics.DEVICE_ATTRIBUTES_RESPONSE_TOPIC_PREFIX + requestId, + JsonConverter.toJson(responseMsg))); + } + return Optional.empty(); + } + } + + @Override + public Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.GetAttributeResponseMsg responseMsg) throws AdaptorException { + if (!StringUtils.isEmpty(responseMsg.getError())) { + throw new AdaptorException(responseMsg.getError()); + } else { + JsonObject result = JsonConverter.getJsonObjectForGateway(responseMsg); + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.GATEWAY_ATTRIBUTES_RESPONSE_TOPIC, result)); + } + } + + @Override + public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_ATTRIBUTES_TOPIC, JsonConverter.toJson(notificationMsg))); + } + + @Override + public Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException { + JsonObject result = JsonConverter.getJsonObjectForGateway(deviceName, notificationMsg); + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.GATEWAY_ATTRIBUTES_TOPIC, result)); + } + + @Override + public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_REQUESTS_TOPIC + rpcRequest.getRequestId(), JsonConverter.toJson(rpcRequest, false))); + } + + @Override + public Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.GATEWAY_RPC_TOPIC, JsonConverter.toGatewayJson(deviceName, rpcRequest))); + } + + @Override + public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.ToServerRpcResponseMsg rpcResponse) { + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC + rpcResponse.getRequestId(), JsonConverter.toJson(rpcResponse))); + } + + private MqttPublishMessage createMqttPublishMsg(MqttDeviceAwareSessionContext ctx, String topic, JsonElement json) { + MqttFixedHeader mqttFixedHeader = + new MqttFixedHeader(MqttMessageType.PUBLISH, false, ctx.getQoSForTopic(topic), false, 0); + MqttPublishVariableHeader header = new MqttPublishVariableHeader(topic, ctx.nextMsgId()); + ByteBuf payload = ALLOCATOR.buffer(); + payload.writeBytes(GSON.toJson(json).getBytes(UTF8)); + return new MqttPublishMessage(mqttFixedHeader, header, payload); + } + + private Set toStringSet(JsonElement requestBody, String name) { + JsonElement element = requestBody.getAsJsonObject().get(name); + if (element != null) { + return new HashSet<>(Arrays.asList(element.getAsString().split(","))); + } else { + return null; + } + } + + public static JsonElement validateJsonPayload(UUID sessionId, ByteBuf payloadData) throws AdaptorException { + String payload = validatePayload(sessionId, payloadData); + try { + return new JsonParser().parse(payload); + } catch (JsonSyntaxException ex) { + throw new AdaptorException(ex); + } + } + + private static String validatePayload(UUID sessionId, ByteBuf payloadData) throws AdaptorException { + try { + String payload = payloadData.toString(UTF8); + if (payload == null) { + log.warn("[{}] Payload is empty!", sessionId); + throw new AdaptorException(new IllegalArgumentException("Payload is empty!")); + } + return payload; + } finally { + payloadData.release(); + } + } + +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java new file mode 100644 index 0000000000..7af0e66e34 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java @@ -0,0 +1,62 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.adaptors; + +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttPublishMessage; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcResponseMsg; +import org.thingsboard.server.transport.mqtt.session.MqttDeviceAwareSessionContext; + +import java.util.Optional; + +/** + * @author Andrew Shvayka + */ +public interface MqttTransportAdaptor { + + PostTelemetryMsg convertToPostTelemetry(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException; + + PostAttributeMsg convertToPostAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException; + + GetAttributeRequestMsg convertToGetAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException; + + ToDeviceRpcResponseMsg convertToDeviceRpcResponse(MqttDeviceAwareSessionContext ctx, MqttPublishMessage mqttMsg) throws AdaptorException; + + ToServerRpcRequestMsg convertToServerRpcRequest(MqttDeviceAwareSessionContext ctx, MqttPublishMessage mqttMsg) throws AdaptorException; + + Optional convertToPublish(MqttDeviceAwareSessionContext ctx, GetAttributeResponseMsg responseMsg) throws AdaptorException; + + Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, GetAttributeResponseMsg responseMsg) throws AdaptorException; + + Optional convertToPublish(MqttDeviceAwareSessionContext ctx, AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; + + Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; + + Optional convertToPublish(MqttDeviceAwareSessionContext ctx, ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException; + + Optional convertToGatewayPublish(MqttDeviceAwareSessionContext ctx, String deviceName, ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException; + + Optional convertToPublish(MqttDeviceAwareSessionContext ctx, ToServerRpcResponseMsg rpcResponse) throws AdaptorException; +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java new file mode 100644 index 0000000000..fb05e0a624 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.session; + +import io.netty.channel.ChannelHandlerContext; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * @author Andrew Shvayka + */ +@Slf4j +public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { + + @Getter + private ChannelHandlerContext channel; + private AtomicInteger msgIdSeq = new AtomicInteger(0); + + public DeviceSessionCtx(UUID sessionId, ConcurrentMap mqttQoSMap) { + super(sessionId, mqttQoSMap); + } + + public void setChannel(ChannelHandlerContext channel) { + this.channel = channel; + } + + public int nextMsgId() { + return msgIdSeq.incrementAndGet(); + } +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java new file mode 100644 index 0000000000..38c1ffff3a --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java @@ -0,0 +1,101 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.session; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; + +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; + +/** + * Created by ashvayka on 19.01.17. + */ +@Slf4j +public class GatewayDeviceSessionCtx extends MqttDeviceAwareSessionContext implements SessionMsgListener { + + private final GatewaySessionHandler parent; + private final SessionInfoProto sessionInfo; + + public GatewayDeviceSessionCtx(GatewaySessionHandler parent, DeviceInfoProto deviceInfo, ConcurrentMap mqttQoSMap) { + super(UUID.randomUUID(), mqttQoSMap); + this.parent = parent; + this.sessionInfo = SessionInfoProto.newBuilder() + .setNodeId(parent.getNodeId()) + .setSessionIdMSB(sessionId.getMostSignificantBits()) + .setSessionIdLSB(sessionId.getLeastSignificantBits()) + .setDeviceIdMSB(deviceInfo.getDeviceIdMSB()) + .setDeviceIdLSB(deviceInfo.getDeviceIdLSB()) + .setTenantIdMSB(deviceInfo.getTenantIdMSB()) + .setTenantIdLSB(deviceInfo.getTenantIdLSB()) + .build(); + setDeviceInfo(deviceInfo); + } + + @Override + public UUID getSessionId() { + return sessionId; + } + + @Override + public int nextMsgId() { + return parent.nextMsgId(); + } + + SessionInfoProto getSessionInfo() { + return sessionInfo; + } + + @Override + public void onGetAttributesResponse(TransportProtos.GetAttributeResponseMsg response) { + try { + parent.getAdaptor().convertToGatewayPublish(this, response).ifPresent(parent::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device attributes response to MQTT msg", sessionId, e); + } + } + + @Override + public void onAttributeUpdate(TransportProtos.AttributeUpdateNotificationMsg notification) { + try { + parent.getAdaptor().convertToGatewayPublish(this, getDeviceInfo().getDeviceName(), notification).ifPresent(parent::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device attributes response to MQTT msg", sessionId, e); + } + } + + @Override + public void onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto sessionCloseNotification) { + parent.deregisterSession(getDeviceInfo().getDeviceName()); + } + + @Override + public void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg request) { + try { + parent.getAdaptor().convertToGatewayPublish(this, getDeviceInfo().getDeviceName(), request).ifPresent(parent::writeAndFlush); + } catch (Exception e) { + log.trace("[{}] Failed to convert device attributes response to MQTT msg", sessionId, e); + } + } + + @Override + public void onToServerRpcResponse(TransportProtos.ToServerRpcResponseMsg toServerResponse) { + // This feature is not supported in the TB IoT Gateway yet. + } +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java new file mode 100644 index 0000000000..37fb84b3e6 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionHandler.java @@ -0,0 +1,375 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.session; + + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.mqtt.MqttMessage; +import io.netty.handler.codec.mqtt.MqttPublishMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.common.transport.adaptor.AdaptorException; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; +import org.thingsboard.server.common.transport.service.AbstractTransportService; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.transport.mqtt.MqttTransportContext; +import org.thingsboard.server.transport.mqtt.MqttTransportHandler; +import org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor; +import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; + +import javax.annotation.Nullable; +import java.util.Collections; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Created by ashvayka on 19.01.17. + */ +@Slf4j +public class GatewaySessionHandler { + + private static final String DEFAULT_DEVICE_TYPE = "default"; + private static final String CAN_T_PARSE_VALUE = "Can't parse value: "; + private static final String DEVICE_PROPERTY = "device"; + + private final MqttTransportContext context; + private final TransportService transportService; + private final DeviceInfoProto gateway; + private final UUID sessionId; + private final Map devices; + private final ConcurrentMap mqttQoSMap; + private final ChannelHandlerContext channel; + private final DeviceSessionCtx deviceSessionCtx; + + public GatewaySessionHandler(MqttTransportContext context, DeviceSessionCtx deviceSessionCtx, UUID sessionId) { + this.context = context; + this.transportService = context.getTransportService(); + this.deviceSessionCtx = deviceSessionCtx; + this.gateway = deviceSessionCtx.getDeviceInfo(); + this.sessionId = sessionId; + this.devices = new ConcurrentHashMap<>(); + this.mqttQoSMap = deviceSessionCtx.getMqttQoSMap(); + this.channel = deviceSessionCtx.getChannel(); + } + + public void onDeviceConnect(MqttPublishMessage msg) throws AdaptorException { + JsonElement json = getJson(msg); + String deviceName = checkDeviceName(getDeviceName(json)); + String deviceType = getDeviceType(json); + log.trace("[{}] onDeviceConnect: {}", sessionId, deviceName); + Futures.addCallback(onDeviceConnect(deviceName, deviceType), new FutureCallback() { + @Override + public void onSuccess(@Nullable GatewayDeviceSessionCtx result) { + ack(msg); + log.trace("[{}] onDeviceConnectOk: {}", sessionId, deviceName); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to process device connect command: {}", sessionId, deviceName, t); + + } + }, context.getExecutor()); + } + + private ListenableFuture onDeviceConnect(String deviceName, String deviceType) { + SettableFuture future = SettableFuture.create(); + GatewayDeviceSessionCtx result = devices.get(deviceName); + if (result == null) { + transportService.process(GetOrCreateDeviceFromGatewayRequestMsg.newBuilder() + .setDeviceName(deviceName) + .setDeviceType(deviceType) + .setGatewayIdMSB(gateway.getDeviceIdMSB()) + .setGatewayIdLSB(gateway.getDeviceIdLSB()).build(), + new TransportServiceCallback() { + @Override + public void onSuccess(GetOrCreateDeviceFromGatewayResponseMsg msg) { + GatewayDeviceSessionCtx deviceSessionCtx = new GatewayDeviceSessionCtx(GatewaySessionHandler.this, msg.getDeviceInfo(), mqttQoSMap); + if (devices.putIfAbsent(deviceName, deviceSessionCtx) == null) { + SessionInfoProto deviceSessionInfo = deviceSessionCtx.getSessionInfo(); + transportService.registerAsyncSession(deviceSessionInfo, deviceSessionCtx); + transportService.process(deviceSessionInfo, AbstractTransportService.getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); + transportService.process(deviceSessionInfo, TransportProtos.SubscribeToRPCMsg.getDefaultInstance(), null); + transportService.process(deviceSessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.getDefaultInstance(), null); + } + future.set(devices.get(deviceName)); + } + + @Override + public void onError(Throwable e) { + log.warn("[{}] Failed to process device connect command: {}", sessionId, deviceName, e); + future.setException(e); + } + }); + } else { + future.set(result); + } + return future; + } + + public void onDeviceDisconnect(MqttPublishMessage msg) throws AdaptorException { + String deviceName = checkDeviceName(getDeviceName(getJson(msg))); + deregisterSession(deviceName); + ack(msg); + } + + void deregisterSession(String deviceName) { + GatewayDeviceSessionCtx deviceSessionCtx = devices.remove(deviceName); + if (deviceSessionCtx != null) { + deregisterSession(deviceName, deviceSessionCtx); + } else { + log.debug("[{}] Device [{}] was already removed from the gateway session", sessionId, deviceName); + } + } + + public void onGatewayDisconnect() { + devices.forEach(this::deregisterSession); + } + + public void onDeviceTelemetry(MqttPublishMessage mqttMsg) throws AdaptorException { + JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, mqttMsg.payload()); + int msgId = mqttMsg.variableHeader().packetId(); + if (json.isJsonObject()) { + JsonObject jsonObj = json.getAsJsonObject(); + for (Map.Entry deviceEntry : jsonObj.entrySet()) { + String deviceName = deviceEntry.getKey(); + Futures.addCallback(checkDeviceConnected(deviceName), + new FutureCallback() { + @Override + public void onSuccess(@Nullable GatewayDeviceSessionCtx deviceCtx) { + if (!deviceEntry.getValue().isJsonArray()) { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + TransportProtos.PostTelemetryMsg postTelemetryMsg = JsonConverter.convertToTelemetryProto(deviceEntry.getValue().getAsJsonArray()); + transportService.process(deviceCtx.getSessionInfo(), postTelemetryMsg, getPubAckCallback(channel, deviceName, msgId, postTelemetryMsg)); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}] Failed to process device teleemtry command: {}", sessionId, deviceName, t); + } + }, context.getExecutor()); + } + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + } + + public void onDeviceAttributes(MqttPublishMessage mqttMsg) throws AdaptorException { + JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, mqttMsg.payload()); + int msgId = mqttMsg.variableHeader().packetId(); + if (json.isJsonObject()) { + JsonObject jsonObj = json.getAsJsonObject(); + for (Map.Entry deviceEntry : jsonObj.entrySet()) { + String deviceName = deviceEntry.getKey(); + Futures.addCallback(checkDeviceConnected(deviceName), + new FutureCallback() { + @Override + public void onSuccess(@Nullable GatewayDeviceSessionCtx deviceCtx) { + if (!deviceEntry.getValue().isJsonObject()) { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + TransportProtos.PostAttributeMsg postAttributeMsg = JsonConverter.convertToAttributesProto(deviceEntry.getValue().getAsJsonObject()); + transportService.process(deviceCtx.getSessionInfo(), postAttributeMsg, getPubAckCallback(channel, deviceName, msgId, postAttributeMsg)); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}] Failed to process device attributes command: {}", sessionId, deviceName, t); + } + }, context.getExecutor()); + } + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + } + + public void onDeviceRpcResponse(MqttPublishMessage mqttMsg) throws AdaptorException { + JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, mqttMsg.payload()); + int msgId = mqttMsg.variableHeader().packetId(); + if (json.isJsonObject()) { + JsonObject jsonObj = json.getAsJsonObject(); + String deviceName = jsonObj.get(DEVICE_PROPERTY).getAsString(); + Futures.addCallback(checkDeviceConnected(deviceName), + new FutureCallback() { + @Override + public void onSuccess(@Nullable GatewayDeviceSessionCtx deviceCtx) { + Integer requestId = jsonObj.get("id").getAsInt(); + String data = jsonObj.get("data").toString(); + TransportProtos.ToDeviceRpcResponseMsg rpcResponseMsg = TransportProtos.ToDeviceRpcResponseMsg.newBuilder() + .setRequestId(requestId).setPayload(data).build(); + transportService.process(deviceCtx.getSessionInfo(), rpcResponseMsg, getPubAckCallback(channel, deviceName, msgId, rpcResponseMsg)); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}] Failed to process device teleemtry command: {}", sessionId, deviceName, t); + } + }, context.getExecutor()); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + } + + public void onDeviceAttributesRequest(MqttPublishMessage msg) throws AdaptorException { + JsonElement json = JsonMqttAdaptor.validateJsonPayload(sessionId, msg.payload()); + if (json.isJsonObject()) { + JsonObject jsonObj = json.getAsJsonObject(); + int requestId = jsonObj.get("id").getAsInt(); + String deviceName = jsonObj.get(DEVICE_PROPERTY).getAsString(); + boolean clientScope = jsonObj.get("client").getAsBoolean(); + Set keys; + if (jsonObj.has("key")) { + keys = Collections.singleton(jsonObj.get("key").getAsString()); + } else { + JsonArray keysArray = jsonObj.get("keys").getAsJsonArray(); + keys = new HashSet<>(); + for (JsonElement keyObj : keysArray) { + keys.add(keyObj.getAsString()); + } + } + TransportProtos.GetAttributeRequestMsg.Builder result = TransportProtos.GetAttributeRequestMsg.newBuilder(); + result.setRequestId(requestId); + + if (clientScope) { + result.addAllClientAttributeNames(keys); + } else { + result.addAllSharedAttributeNames(keys); + } + TransportProtos.GetAttributeRequestMsg requestMsg = result.build(); + int msgId = msg.variableHeader().packetId(); + Futures.addCallback(checkDeviceConnected(deviceName), + new FutureCallback() { + @Override + public void onSuccess(@Nullable GatewayDeviceSessionCtx deviceCtx) { + transportService.process(deviceCtx.getSessionInfo(), requestMsg, getPubAckCallback(channel, deviceName, msgId, requestMsg)); + } + + @Override + public void onFailure(Throwable t) { + log.debug("[{}] Failed to process device attributes request command: {}", sessionId, deviceName, t); + } + }, context.getExecutor()); + ack(msg); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); + } + } + + private ListenableFuture checkDeviceConnected(String deviceName) { + GatewayDeviceSessionCtx ctx = devices.get(deviceName); + if (ctx == null) { + log.debug("[{}] Missing device [{}] for the gateway session", sessionId, deviceName); + return onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE); + } else { + return Futures.immediateFuture(ctx); + } + } + + private String checkDeviceName(String deviceName) { + if (StringUtils.isEmpty(deviceName)) { + throw new RuntimeException("Device name is empty!"); + } else { + return deviceName; + } + } + + private String getDeviceName(JsonElement json) throws AdaptorException { + return json.getAsJsonObject().get(DEVICE_PROPERTY).getAsString(); + } + + private String getDeviceType(JsonElement json) throws AdaptorException { + JsonElement type = json.getAsJsonObject().get("type"); + return type == null || type instanceof JsonNull ? DEFAULT_DEVICE_TYPE : type.getAsString(); + } + + private JsonElement getJson(MqttPublishMessage mqttMsg) throws AdaptorException { + return JsonMqttAdaptor.validateJsonPayload(sessionId, mqttMsg.payload()); + } + + private void ack(MqttPublishMessage msg) { + if (msg.variableHeader().packetId() > 0) { + writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(msg.variableHeader().packetId())); + } + } + + void writeAndFlush(MqttMessage mqttMessage) { + channel.writeAndFlush(mqttMessage); + } + + public String getNodeId() { + return context.getNodeId(); + } + + private void deregisterSession(String deviceName, GatewayDeviceSessionCtx deviceSessionCtx) { + transportService.deregisterSession(deviceSessionCtx.getSessionInfo()); + transportService.process(deviceSessionCtx.getSessionInfo(), AbstractTransportService.getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null); + log.debug("[{}] Removed device [{}] from the gateway session", sessionId, deviceName); + } + + private TransportServiceCallback getPubAckCallback(final ChannelHandlerContext ctx, final String deviceName, final int msgId, final T msg) { + return new TransportServiceCallback() { + @Override + public void onSuccess(Void dummy) { + log.trace("[{}][{}] Published msg: {}", sessionId, deviceName, msg); + if (msgId > 0) { + ctx.writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(msgId)); + } + } + + @Override + public void onError(Throwable e) { + log.trace("[{}] Failed to publish msg: {}", sessionId, deviceName, msg, e); + ctx.close(); + } + }; + } + + public MqttTransportContext getContext() { + return context; + } + + MqttTransportAdaptor getAdaptor() { + return context.getAdaptor(); + } + + int nextMsgId() { + return deviceSessionCtx.nextMsgId(); + } + + public void reportActivity() { + devices.forEach((id, deviceCtx) -> transportService.reportActivity(deviceCtx.getSessionInfo())); + } +} diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java new file mode 100644 index 0000000000..745b0ed444 --- /dev/null +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttDeviceAwareSessionContext.java @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt.session; + +import io.netty.handler.codec.mqtt.MqttQoS; +import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; +import java.util.stream.Collectors; + +/** + * Created by ashvayka on 30.08.18. + */ +public abstract class MqttDeviceAwareSessionContext extends DeviceAwareSessionContext { + + private final ConcurrentMap mqttQoSMap; + + public MqttDeviceAwareSessionContext(UUID sessionId, ConcurrentMap mqttQoSMap) { + super(sessionId); + this.mqttQoSMap = mqttQoSMap; + } + + public ConcurrentMap getMqttQoSMap() { + return mqttQoSMap; + } + + public MqttQoS getQoSForTopic(String topic) { + List qosList = mqttQoSMap.entrySet() + .stream() + .filter(entry -> entry.getKey().matches(topic)) + .map(Map.Entry::getValue) + .collect(Collectors.toList()); + if (!qosList.isEmpty()) { + return MqttQoS.valueOf(qosList.get(0)); + } else { + return MqttQoS.AT_LEAST_ONCE; + } + } + +} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java similarity index 56% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java index 03006128bf..4d48223fcd 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttSessionId.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/MqttTopicMatcher.java @@ -15,22 +15,27 @@ */ package org.thingsboard.server.transport.mqtt.session; -import org.thingsboard.server.common.data.id.SessionId; +import java.util.regex.Pattern; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicLong; +public class MqttTopicMatcher { -/** - * @author Andrew Shvayka - */ -public class MqttSessionId implements SessionId { + private final String topic; + private final Pattern topicRegex; - private static final AtomicLong idSeq = new AtomicLong(); + public MqttTopicMatcher(String topic) { + if(topic == null){ + throw new NullPointerException("topic"); + } + this.topic = topic; + this.topicRegex = Pattern.compile(topic.replace("+", "[^/]+").replace("#", ".+") + "$"); + } - private final long id; + public String getTopic() { + return topic; + } - public MqttSessionId() { - this.id = idSeq.incrementAndGet(); + public boolean matches(String topic){ + return this.topicRegex.matcher(topic).matches(); } @Override @@ -38,26 +43,13 @@ public class MqttSessionId implements SessionId { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; - MqttSessionId that = (MqttSessionId) o; - - return id == that.id; + MqttTopicMatcher that = (MqttTopicMatcher) o; - } - - @Override - public String toString() { - return "MqttSessionId{" + - "id=" + id + - '}'; + return topic.equals(that.topic); } @Override public int hashCode() { - return (int) (id ^ (id >>> 32)); - } - - @Override - public String toUidStr() { - return "mqtt" + id; + return topic.hashCode(); } } diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java similarity index 96% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java rename to common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java index 96cdf142a6..d98d1b91bc 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/util/SslUtil.java @@ -17,7 +17,7 @@ package org.thingsboard.server.transport.mqtt.util; import lombok.extern.slf4j.Slf4j; import org.springframework.util.Base64Utils; -import org.thingsboard.server.dao.EncryptionUtil; +import org.thingsboard.server.common.msg.EncryptionUtil; import java.io.IOException; import java.security.cert.CertificateEncodingException; diff --git a/common/transport/pom.xml b/common/transport/pom.xml index e132ffb01e..83e7ba806a 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -16,76 +16,29 @@ --> + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT common org.thingsboard.common transport - jar + pom - Thingsboard Server Common Transport components + Thingsboard Server Commons https://thingsboard.io UTF-8 ${basedir}/../.. - - - - org.thingsboard.common - data - - - org.thingsboard.common - message - - - com.google.code.gson - gson - - - org.slf4j - slf4j-api - - - org.slf4j - log4j-over-slf4j - - - ch.qos.logback - logback-core - - - ch.qos.logback - logback-classic - - - junit - junit - test - - - org.mockito - mockito-all - test - - - org.springframework - spring-context - - - com.google.guava - guava - - - org.apache.commons - commons-lang3 - - + + transport-api + mqtt + http + coap + diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java deleted file mode 100644 index 080f874efb..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport; - -import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; - -import java.util.Optional; - -public interface TransportAdaptor { - - AdaptorToSessionActorMsg convertToActorMsg(C ctx, SessionMsgType type, T inbound) throws AdaptorException; - - Optional convertToAdaptorMsg(C ctx, SessionActorToAdaptorMsg msg) throws AdaptorException; - -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java deleted file mode 100644 index e7c173485c..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.adaptor; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map.Entry; -import java.util.function.Consumer; -import java.util.stream.Collectors; - -import com.google.gson.*; -import org.thingsboard.server.common.msg.core.*; - -import org.thingsboard.server.common.data.kv.*; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; - -public class JsonConverter { - - private static final Gson GSON = new Gson(); - public static final String CAN_T_PARSE_VALUE = "Can't parse value: "; - - public static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject) throws JsonSyntaxException { - return convertToTelemetry(jsonObject, BasicRequest.DEFAULT_REQUEST_ID); - } - - public static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject, long ts) throws JsonSyntaxException { - return convertToTelemetry(jsonObject, ts, BasicRequest.DEFAULT_REQUEST_ID); - } - - public static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject, int requestId) throws JsonSyntaxException { - return convertToTelemetry(jsonObject, System.currentTimeMillis(), requestId); - } - - private static TelemetryUploadRequest convertToTelemetry(JsonElement jsonObject, long systemTs, int requestId) throws JsonSyntaxException { - BasicTelemetryUploadRequest request = new BasicTelemetryUploadRequest(requestId); - if (jsonObject.isJsonObject()) { - parseObject(request, systemTs, jsonObject); - } else if (jsonObject.isJsonArray()) { - jsonObject.getAsJsonArray().forEach(je -> { - if (je.isJsonObject()) { - parseObject(request, systemTs, je.getAsJsonObject()); - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + je); - } - }); - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + jsonObject); - } - return request; - } - - public static ToServerRpcRequestMsg convertToServerRpcRequest(JsonElement json, int requestId) throws JsonSyntaxException { - JsonObject object = json.getAsJsonObject(); - return new ToServerRpcRequestMsg(requestId, object.get("method").getAsString(), GSON.toJson(object.get("params"))); - } - - private static void parseObject(BasicTelemetryUploadRequest request, long systemTs, JsonElement jsonObject) { - JsonObject jo = jsonObject.getAsJsonObject(); - if (jo.has("ts") && jo.has("values")) { - parseWithTs(request, jo); - } else { - parseWithoutTs(request, systemTs, jo); - } - } - - private static void parseWithoutTs(BasicTelemetryUploadRequest request, long systemTs, JsonObject jo) { - for (KvEntry entry : parseValues(jo)) { - request.add(systemTs, entry); - } - } - - public static void parseWithTs(BasicTelemetryUploadRequest request, JsonObject jo) { - long ts = jo.get("ts").getAsLong(); - JsonObject valuesObject = jo.get("values").getAsJsonObject(); - for (KvEntry entry : parseValues(valuesObject)) { - request.add(ts, entry); - } - } - - public static List parseValues(JsonObject valuesObject) { - List result = new ArrayList<>(); - for (Entry valueEntry : valuesObject.entrySet()) { - JsonElement element = valueEntry.getValue(); - if (element.isJsonPrimitive()) { - JsonPrimitive value = element.getAsJsonPrimitive(); - if (value.isString()) { - result.add(new StringDataEntry(valueEntry.getKey(), value.getAsString())); - } else if (value.isBoolean()) { - result.add(new BooleanDataEntry(valueEntry.getKey(), value.getAsBoolean())); - } else if (value.isNumber()) { - parseNumericValue(result, valueEntry, value); - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + value); - } - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + element); - } - } - return result; - } - - private static void parseNumericValue(List result, Entry valueEntry, JsonPrimitive value) { - if (value.getAsString().contains(".")) { - result.add(new DoubleDataEntry(valueEntry.getKey(), value.getAsDouble())); - } else { - try { - long longValue = Long.parseLong(value.getAsString()); - result.add(new LongDataEntry(valueEntry.getKey(), longValue)); - } catch (NumberFormatException e) { - throw new JsonSyntaxException("Big integer values are not supported!"); - } - } - } - - public static AttributesUpdateRequest convertToAttributes(JsonElement element) { - return convertToAttributes(element, BasicRequest.DEFAULT_REQUEST_ID); - } - - public static AttributesUpdateRequest convertToAttributes(JsonElement element, int requestId) { - if (element.isJsonObject()) { - BasicAttributesUpdateRequest request = new BasicAttributesUpdateRequest(requestId); - long ts = System.currentTimeMillis(); - request.add(parseValues(element.getAsJsonObject()).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).collect(Collectors.toList())); - return request; - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + element); - } - } - - public static JsonObject toJson(AttributesKVMsg payload, boolean asMap) { - JsonObject result = new JsonObject(); - if (asMap) { - if (!payload.getClientAttributes().isEmpty()) { - JsonObject attrObject = new JsonObject(); - payload.getClientAttributes().forEach(addToObject(attrObject)); - result.add("client", attrObject); - } - if (!payload.getSharedAttributes().isEmpty()) { - JsonObject attrObject = new JsonObject(); - payload.getSharedAttributes().forEach(addToObject(attrObject)); - result.add("shared", attrObject); - } - } else { - payload.getClientAttributes().forEach(addToObject(result)); - payload.getSharedAttributes().forEach(addToObject(result)); - } - if (!payload.getDeletedAttributes().isEmpty()) { - JsonArray attrObject = new JsonArray(); - payload.getDeletedAttributes().forEach(addToObject(attrObject)); - result.add("deleted", attrObject); - } - return result; - } - - private static Consumer addToObject(JsonArray result) { - return key -> { - result.add(key.getAttributeKey()); - }; - } - - private static Consumer addToObject(JsonObject result) { - return de -> { - JsonPrimitive value; - switch (de.getDataType()) { - case BOOLEAN: - value = new JsonPrimitive(de.getBooleanValue().get()); - break; - case DOUBLE: - value = new JsonPrimitive(de.getDoubleValue().get()); - break; - case LONG: - value = new JsonPrimitive(de.getLongValue().get()); - break; - case STRING: - value = new JsonPrimitive(de.getStrValue().get()); - break; - default: - throw new IllegalArgumentException("Unsupported data type: " + de.getDataType()); - } - result.add(de.getKey(), value); - }; - } - - public static JsonObject toJson(ToDeviceRpcRequestMsg msg, boolean includeRequestId) { - JsonObject result = new JsonObject(); - if (includeRequestId) { - result.addProperty("id", msg.getRequestId()); - } - result.addProperty("method", msg.getMethod()); - result.add("params", new JsonParser().parse(msg.getParams())); - return result; - } - - public static JsonElement toJson(ToServerRpcResponseMsg msg) { - return new JsonParser().parse(msg.getData()); - } - - public static JsonElement toErrorJson(String errorMsg) { - JsonObject error = new JsonObject(); - error.addProperty("error", errorMsg); - return error; - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java deleted file mode 100644 index 9d7bd1720c..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/AbstractQuotaService.java +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryCleaner; -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryLogger; -import org.thingsboard.server.common.transport.quota.inmemory.KeyBasedIntervalRegistry; - -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; - -public class AbstractQuotaService implements QuotaService { - - private final KeyBasedIntervalRegistry requestRegistry; - private final RequestLimitPolicy requestsPolicy; - private final IntervalRegistryCleaner registryCleaner; - private final IntervalRegistryLogger registryLogger; - private final boolean enabled; - - public AbstractQuotaService(KeyBasedIntervalRegistry requestRegistry, RequestLimitPolicy requestsPolicy, - IntervalRegistryCleaner registryCleaner, IntervalRegistryLogger registryLogger, - boolean enabled) { - this.requestRegistry = requestRegistry; - this.requestsPolicy = requestsPolicy; - this.registryCleaner = registryCleaner; - this.registryLogger = registryLogger; - this.enabled = enabled; - } - - @PostConstruct - public void init() { - if (enabled) { - registryCleaner.schedule(); - registryLogger.schedule(); - } - } - - @PreDestroy - public void close() { - if (enabled) { - registryCleaner.stop(); - registryLogger.stop(); - } - } - - @Override - public boolean isQuotaExceeded(String key) { - if (enabled) { - long count = requestRegistry.tick(key); - return !requestsPolicy.isValid(count); - } - return false; - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java deleted file mode 100644 index dcc2d2306c..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/Clock.java +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public final class Clock { - - private static long time = 0L; - - private Clock() { - } - - - public static long millis() { - return time == 0 ? System.currentTimeMillis() : time; - } - - public static void setMillis(long millis) { - time = millis; - } - - public static void shift(long delta) { - time += delta; - } - - public static void reset() { - time = 0; - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java deleted file mode 100644 index ea75f5dc09..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryCleaner.java +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.host; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryCleaner; - -@Component -public class HostIntervalRegistryCleaner extends IntervalRegistryCleaner { - - public HostIntervalRegistryCleaner(HostRequestIntervalRegistry intervalRegistry, - @Value("${quota.host.cleanPeriodMs}") long cleanPeriodMs) { - super(intervalRegistry, cleanPeriodMs); - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java deleted file mode 100644 index 65767f1648..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostIntervalRegistryLogger.java +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.host; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryLogger; - -import java.util.Map; -import java.util.concurrent.TimeUnit; - -@Component -@Slf4j -public class HostIntervalRegistryLogger extends IntervalRegistryLogger { - - private final long logIntervalMin; - - public HostIntervalRegistryLogger(@Value("${quota.host.log.topSize}") int topSize, - @Value("${quota.host.log.intervalMin}") long logIntervalMin, - HostRequestIntervalRegistry intervalRegistry) { - super(topSize, logIntervalMin, intervalRegistry); - this.logIntervalMin = logIntervalMin; - } - - protected void log(Map top, int uniqHosts, long requestsCount) { - long rps = requestsCount / TimeUnit.MINUTES.toSeconds(logIntervalMin); - StringBuilder builder = new StringBuilder("Quota Statistic : "); - builder.append("uniqHosts : ").append(uniqHosts).append("; "); - builder.append("requestsCount : ").append(requestsCount).append("; "); - builder.append("RPS : ").append(rps).append(" "); - builder.append("top -> "); - for (Map.Entry host : top.entrySet()) { - builder.append(host.getKey()).append(" : ").append(host.getValue()).append("; "); - } - - log.info(builder.toString()); - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java deleted file mode 100644 index 69342b538b..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestsQuotaService.java +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.host; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Service; -import org.thingsboard.server.common.transport.quota.AbstractQuotaService; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -@Service -@Slf4j -public class HostRequestsQuotaService extends AbstractQuotaService { - - public HostRequestsQuotaService(HostRequestIntervalRegistry requestRegistry, HostRequestLimitPolicy requestsPolicy, - HostIntervalRegistryCleaner registryCleaner, HostIntervalRegistryLogger registryLogger, - @Value("${quota.host.enabled}") boolean enabled) { - super(requestRegistry, requestsPolicy, registryCleaner, registryLogger, enabled); - } - -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java deleted file mode 100644 index 0ac6c9b5e2..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCount.java +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - - -import org.thingsboard.server.common.transport.quota.Clock; - -import java.util.concurrent.atomic.LongAdder; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class IntervalCount { - - private final LongAdder adder = new LongAdder(); - private final long intervalDurationMs; - private volatile long startTime; - private volatile long lastTickTime; - - public IntervalCount(long intervalDurationMs) { - this.intervalDurationMs = intervalDurationMs; - startTime = Clock.millis(); - } - - public long resetIfExpiredAndTick() { - if (isExpired()) { - reset(); - } - tick(); - return adder.sum(); - } - - public long silenceDuration() { - return Clock.millis() - lastTickTime; - } - - public long getCount() { - return adder.sum(); - } - - private void tick() { - adder.add(1); - lastTickTime = Clock.millis(); - } - - private void reset() { - adder.reset(); - startTime = Clock.millis(); - } - - private boolean isExpired() { - return (Clock.millis() - startTime) > intervalDurationMs; - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java deleted file mode 100644 index 0c510ff815..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryCleaner.java +++ /dev/null @@ -1,62 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import lombok.extern.slf4j.Slf4j; - -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -@Slf4j -public abstract class IntervalRegistryCleaner { - - private final KeyBasedIntervalRegistry intervalRegistry; - private final long cleanPeriodMs; - private ScheduledExecutorService executor; - - public IntervalRegistryCleaner(KeyBasedIntervalRegistry intervalRegistry, long cleanPeriodMs) { - this.intervalRegistry = intervalRegistry; - this.cleanPeriodMs = cleanPeriodMs; - } - - public void schedule() { - if (executor != null) { - throw new IllegalStateException("Registry Cleaner already scheduled"); - } - executor = Executors.newSingleThreadScheduledExecutor(); - executor.scheduleAtFixedRate(this::clean, cleanPeriodMs, cleanPeriodMs, TimeUnit.MILLISECONDS); - } - - public void stop() { - if (executor != null) { - executor.shutdown(); - } - } - - public void clean() { - try { - intervalRegistry.clean(); - } catch (RuntimeException ex) { - log.error("Could not clear Interval Registry", ex); - } - } - -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java deleted file mode 100644 index 30399a140f..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLogger.java +++ /dev/null @@ -1,79 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import com.google.common.collect.MinMaxPriorityQueue; -import lombok.extern.slf4j.Slf4j; - -import java.util.Comparator; -import java.util.Map; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.function.Function; -import java.util.stream.Collectors; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -@Slf4j -public abstract class IntervalRegistryLogger { - - private final int topSize; - private final KeyBasedIntervalRegistry intervalRegistry; - private final long logIntervalMin; - private ScheduledExecutorService executor; - - public IntervalRegistryLogger(int topSize, long logIntervalMin, KeyBasedIntervalRegistry intervalRegistry) { - this.topSize = topSize; - this.logIntervalMin = logIntervalMin; - this.intervalRegistry = intervalRegistry; - } - - public void schedule() { - if (executor != null) { - throw new IllegalStateException("Registry Cleaner already scheduled"); - } - executor = Executors.newSingleThreadScheduledExecutor(); - executor.scheduleAtFixedRate(this::logStatistic, logIntervalMin, logIntervalMin, TimeUnit.MINUTES); - } - - public void stop() { - if (executor != null) { - executor.shutdown(); - } - } - - public void logStatistic() { - Map registryContent = intervalRegistry.getContent(); - int uniqHosts = registryContent.size(); - long requestsCount = registryContent.values().stream().mapToLong(i -> i).sum(); - Map top = getTopElements(registryContent); - log(top, uniqHosts, requestsCount); - } - - protected Map getTopElements(Map countMap) { - MinMaxPriorityQueue> topQueue = MinMaxPriorityQueue - .orderedBy(Comparator.comparing((Function, Long>) Map.Entry::getValue).reversed()) - .maximumSize(topSize) - .create(countMap.entrySet()); - - return topQueue.stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); - } - - protected abstract void log(Map top, int uniqHosts, long requestsCount); -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java deleted file mode 100644 index 0b0fef85e5..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/inmemory/KeyBasedIntervalRegistry.java +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import com.google.common.collect.Sets; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.StringUtils; - -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Collectors; - -@Slf4j -public abstract class KeyBasedIntervalRegistry { - - private final Map hostCounts = new ConcurrentHashMap<>(); - private final long intervalDurationMs; - private final long ttlMs; - private final Set whiteList; - private final Set blackList; - - public KeyBasedIntervalRegistry(long intervalDurationMs, long ttlMs, String whiteList, String blackList, String name) { - this.intervalDurationMs = intervalDurationMs; - this.ttlMs = ttlMs; - this.whiteList = Sets.newHashSet(StringUtils.split(whiteList, ',')); - this.blackList = Sets.newHashSet(StringUtils.split(blackList, ',')); - validate(name); - } - - private void validate(String name) { - if (ttlMs < intervalDurationMs) { - log.warn("TTL for {} IntervalRegistry [{}] smaller than interval duration [{}]", name, ttlMs, intervalDurationMs); - } - log.info("Start {} KeyBasedIntervalRegistry with whitelist {}", name, whiteList); - log.info("Start {} KeyBasedIntervalRegistry with blacklist {}", name, blackList); - } - - public long tick(String clientHostId) { - IntervalCount intervalCount = hostCounts.computeIfAbsent(clientHostId, s -> new IntervalCount(intervalDurationMs)); - long currentCount = intervalCount.resetIfExpiredAndTick(); - if (whiteList.contains(clientHostId)) { - return 0; - } else if (blackList.contains(clientHostId)) { - return Long.MAX_VALUE; - } - return currentCount; - } - - public void clean() { - hostCounts.entrySet().removeIf(entry -> entry.getValue().silenceDuration() > ttlMs); - } - - public Map getContent() { - return hostCounts.entrySet().stream() - .collect(Collectors.toMap( - Map.Entry::getKey, - interval -> interval.getValue().getCount())); - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java deleted file mode 100644 index c48117069a..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryCleaner.java +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.tenant; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryCleaner; - -@Component -public class TenantIntervalRegistryCleaner extends IntervalRegistryCleaner { - - public TenantIntervalRegistryCleaner(TenantMsgsIntervalRegistry intervalRegistry, - @Value("${quota.rule.tenant.cleanPeriodMs}") long cleanPeriodMs) { - super(intervalRegistry, cleanPeriodMs); - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java deleted file mode 100644 index c56f457423..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantIntervalRegistryLogger.java +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.tenant; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.IntervalRegistryLogger; - -import java.util.Map; -import java.util.concurrent.TimeUnit; - -@Slf4j -@Component -public class TenantIntervalRegistryLogger extends IntervalRegistryLogger { - - private final long logIntervalMin; - - public TenantIntervalRegistryLogger(@Value("${quota.rule.tenant.log.topSize}") int topSize, - @Value("${quota.rule.tenant.log.intervalMin}") long logIntervalMin, - TenantMsgsIntervalRegistry intervalRegistry) { - super(topSize, logIntervalMin, intervalRegistry); - this.logIntervalMin = logIntervalMin; - } - - protected void log(Map top, int uniqHosts, long requestsCount) { - long rps = requestsCount / TimeUnit.MINUTES.toSeconds(logIntervalMin); - StringBuilder builder = new StringBuilder("Tenant Quota Statistic : "); - builder.append("uniqTenants : ").append(uniqHosts).append("; "); - builder.append("requestsCount : ").append(requestsCount).append("; "); - builder.append("RPS : ").append(rps).append(" "); - builder.append("top -> "); - for (Map.Entry host : top.entrySet()) { - builder.append(host.getKey()).append(" : ").append(host.getValue()).append("; "); - } - - log.info(builder.toString()); - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java deleted file mode 100644 index 6e8402c701..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantMsgsIntervalRegistry.java +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.tenant; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.inmemory.KeyBasedIntervalRegistry; - -@Component -public class TenantMsgsIntervalRegistry extends KeyBasedIntervalRegistry { - - public TenantMsgsIntervalRegistry(@Value("${quota.rule.tenant.intervalMs}") long intervalDurationMs, - @Value("${quota.rule.tenant.ttlMs}") long ttlMs, - @Value("${quota.rule.tenant.whitelist}") String whiteList, - @Value("${quota.rule.tenant.blacklist}") String blackList) { - super(intervalDurationMs, ttlMs, whiteList, blackList, "Rule Tenant"); - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java deleted file mode 100644 index a68860a2e0..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/tenant/TenantQuotaService.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.tenant; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.AbstractQuotaService; - -@Component -public class TenantQuotaService extends AbstractQuotaService { - - public TenantQuotaService(TenantMsgsIntervalRegistry requestRegistry, TenantRequestLimitPolicy requestsPolicy, - TenantIntervalRegistryCleaner registryCleaner, TenantIntervalRegistryLogger registryLogger, - @Value("${quota.rule.tenant.enabled}") boolean enabled) { - super(requestRegistry, requestsPolicy, registryCleaner, registryLogger, enabled); - } -} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java deleted file mode 100644 index f0fb51e6e8..0000000000 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.session; - -import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; -import org.thingsboard.server.common.data.security.DeviceTokenCredentials; -import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.auth.DeviceAuthResult; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; - -import java.util.Optional; - -/** - * @author Andrew Shvayka - */ -@Slf4j -public abstract class DeviceAwareSessionContext implements SessionContext { - - protected final DeviceAuthService authService; - protected final SessionMsgProcessor processor; - - protected volatile Device device; - - public DeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService) { - this.processor = processor; - this.authService = authService; - } - - public DeviceAwareSessionContext(SessionMsgProcessor processor, DeviceAuthService authService, Device device) { - this(processor, authService); - this.device = device; - } - - - public boolean login(DeviceCredentialsFilter credentials) { - DeviceAuthResult result = authService.process(credentials); - if (result.isSuccess()) { - Optional deviceOpt = authService.findDeviceById(result.getDeviceId()); - if (deviceOpt.isPresent()) { - device = deviceOpt.get(); - } - return true; - } else { - log.debug("Can't find device using credentials [{}] due to {}", credentials, result.getErrorMsg()); - return false; - } - } - - public DeviceAuthService getAuthService() { - return authService; - } - - public SessionMsgProcessor getProcessor() { - return processor; - } - - public Device getDevice() { - return device; - } -} diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java b/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java deleted file mode 100644 index 403bdc65b3..0000000000 --- a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/ClockTest.java +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; - -import static org.junit.Assert.*; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class ClockTest { - - @Before - public void init() { - Clock.reset(); - } - - @After - public void clear() { - Clock.reset(); - } - - @Test - public void defaultClockUseSystemTime() { - assertFalse(Clock.millis() > System.currentTimeMillis()); - } - - @Test - public void timeCanBeSet() { - Clock.setMillis(100L); - assertEquals(100L, Clock.millis()); - } - - @Test - public void clockCanBeReseted() { - Clock.setMillis(100L); - assertEquals(100L, Clock.millis()); - Clock.reset(); - assertFalse(Clock.millis() > System.currentTimeMillis()); - } - - @Test - public void timeIsShifted() { - Clock.setMillis(100L); - Clock.shift(50L); - assertEquals(150L, Clock.millis()); - } - -} \ No newline at end of file diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java b/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java deleted file mode 100644 index 07e03ef0ac..0000000000 --- a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestLimitPolicyTest.java +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -import org.junit.Test; -import org.thingsboard.server.common.transport.quota.host.HostRequestLimitPolicy; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class HostRequestLimitPolicyTest { - - private HostRequestLimitPolicy limitPolicy = new HostRequestLimitPolicy(10L); - - @Test - public void ifCurrentValueLessThenLimitItIsValid() { - assertTrue(limitPolicy.isValid(9)); - } - - @Test - public void ifCurrentValueEqualsToLimitItIsValid() { - assertTrue(limitPolicy.isValid(10)); - } - - @Test - public void ifCurrentValueGreaterThenLimitItIsValid() { - assertFalse(limitPolicy.isValid(11)); - } - -} \ No newline at end of file diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java b/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java deleted file mode 100644 index 20f8a55d0d..0000000000 --- a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/HostRequestsQuotaServiceTest.java +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota; - -import org.junit.Before; -import org.junit.Test; -import org.thingsboard.server.common.transport.quota.host.*; - -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.mockito.Mockito.*; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class HostRequestsQuotaServiceTest { - - private HostRequestsQuotaService quotaService; - - private HostRequestIntervalRegistry requestRegistry = mock(HostRequestIntervalRegistry.class); - private HostRequestLimitPolicy requestsPolicy = mock(HostRequestLimitPolicy.class); - private HostIntervalRegistryCleaner registryCleaner = mock(HostIntervalRegistryCleaner.class); - private HostIntervalRegistryLogger registryLogger = mock(HostIntervalRegistryLogger.class); - - @Before - public void init() { - quotaService = new HostRequestsQuotaService(requestRegistry, requestsPolicy, registryCleaner, registryLogger, true); - } - - @Test - public void quotaExceededIfRequestCountBiggerThanAllowed() { - when(requestRegistry.tick("key")).thenReturn(10L); - when(requestsPolicy.isValid(10L)).thenReturn(false); - - assertTrue(quotaService.isQuotaExceeded("key")); - - verify(requestRegistry).tick("key"); - verify(requestsPolicy).isValid(10L); - verifyNoMoreInteractions(requestRegistry, requestsPolicy); - } - - @Test - public void quotaNotExceededIfRequestCountLessThanAllowed() { - when(requestRegistry.tick("key")).thenReturn(10L); - when(requestsPolicy.isValid(10L)).thenReturn(true); - - assertFalse(quotaService.isQuotaExceeded("key")); - - verify(requestRegistry).tick("key"); - verify(requestsPolicy).isValid(10L); - verifyNoMoreInteractions(requestRegistry, requestsPolicy); - } - - @Test - public void serviceCanBeDisabled() { - quotaService = new HostRequestsQuotaService(requestRegistry, requestsPolicy, registryCleaner, registryLogger, false); - assertFalse(quotaService.isQuotaExceeded("key")); - verifyNoMoreInteractions(requestRegistry, requestsPolicy); - } -} \ No newline at end of file diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java b/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java deleted file mode 100644 index b49dd00bc9..0000000000 --- a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/HostRequestIntervalRegistryTest.java +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import org.junit.Before; -import org.junit.Test; -import org.thingsboard.server.common.transport.quota.host.HostRequestIntervalRegistry; - -import static org.junit.Assert.assertEquals; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class HostRequestIntervalRegistryTest { - - private HostRequestIntervalRegistry registry; - - @Before - public void init() { - registry = new HostRequestIntervalRegistry(10000L, 100L,"g1,g2", "b1"); - } - - @Test - public void newHostCreateNewInterval() { - assertEquals(1L, registry.tick("host1")); - } - - @Test - public void existingHostUpdated() { - registry.tick("aaa"); - assertEquals(1L, registry.tick("bbb")); - assertEquals(2L, registry.tick("aaa")); - } - - @Test - public void expiredIntervalsCleaned() throws InterruptedException { - registry.tick("aaa"); - Thread.sleep(150L); - registry.tick("bbb"); - registry.clean(); - assertEquals(1L, registry.tick("aaa")); - assertEquals(2L, registry.tick("bbb")); - } - - @Test - public void domainFromWhitelistNotCounted(){ - assertEquals(0L, registry.tick("g1")); - assertEquals(0L, registry.tick("g1")); - assertEquals(0L, registry.tick("g2")); - } - - @Test - public void domainFromBlackListReturnMaxValue(){ - assertEquals(Long.MAX_VALUE, registry.tick("b1")); - assertEquals(Long.MAX_VALUE, registry.tick("b1")); - } - - @Test - public void emptyWhitelistParsedOk(){ - registry = new HostRequestIntervalRegistry(10000L, 100L,"", "b1"); - assertEquals(1L, registry.tick("aaa")); - } - - @Test - public void emptyBlacklistParsedOk(){ - registry = new HostRequestIntervalRegistry(10000L, 100L,"", ""); - assertEquals(1L, registry.tick("aaa")); - } -} \ No newline at end of file diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java b/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java deleted file mode 100644 index 4fdce61fd3..0000000000 --- a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalCountTest.java +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import org.junit.After; -import org.junit.Before; -import org.junit.Test; -import org.thingsboard.server.common.transport.quota.Clock; - -import static org.junit.Assert.assertEquals; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class IntervalCountTest { - - @Before - public void init() { - Clock.setMillis(1000L); - } - - @After - public void clear() { - Clock.reset(); - } - - @Test - public void ticksInSameIntervalAreSummed() { - IntervalCount intervalCount = new IntervalCount(100L); - assertEquals(1L, intervalCount.resetIfExpiredAndTick()); - Clock.shift(100); - assertEquals(2L, intervalCount.resetIfExpiredAndTick()); - } - - @Test - public void oldDataCleanedWhenIntervalExpired() { - IntervalCount intervalCount = new IntervalCount(100L); - assertEquals(1L, intervalCount.resetIfExpiredAndTick()); - Clock.shift(101); - assertEquals(1L, intervalCount.resetIfExpiredAndTick()); - } - - @Test - public void silenceDurationCalculatedFromLastTick() { - IntervalCount intervalCount = new IntervalCount(100L); - assertEquals(1L, intervalCount.resetIfExpiredAndTick()); - Clock.shift(10L); - assertEquals(10L, intervalCount.silenceDuration()); - } - -} \ No newline at end of file diff --git a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java b/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java deleted file mode 100644 index 6e51420cd5..0000000000 --- a/common/transport/src/test/java/org/thingsboard/server/common/transport/quota/inmemory/IntervalRegistryLoggerTest.java +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.common.transport.quota.inmemory; - -import com.google.common.collect.ImmutableMap; -import org.junit.Before; -import org.junit.Test; -import org.thingsboard.server.common.transport.quota.host.HostIntervalRegistryLogger; -import org.thingsboard.server.common.transport.quota.host.HostRequestIntervalRegistry; - -import java.util.Collections; -import java.util.Map; - -import static org.junit.Assert.assertEquals; -import static org.mockito.Mockito.mock; - -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -public class IntervalRegistryLoggerTest { - - private IntervalRegistryLogger logger; - - private HostRequestIntervalRegistry requestRegistry = mock(HostRequestIntervalRegistry.class); - - @Before - public void init() { - logger = new HostIntervalRegistryLogger(3, 10, requestRegistry); - } - - @Test - public void onlyMaxHostsCollected() { - Map map = ImmutableMap.of("a", 8L, "b", 3L, "c", 1L, "d", 3L); - Map actual = logger.getTopElements(map); - Map expected = ImmutableMap.of("a", 8L, "b", 3L, "d", 3L); - - assertEquals(expected, actual); - } - - @Test - public void emptyMapProcessedCorrectly() { - Map map = Collections.emptyMap(); - Map actual = logger.getTopElements(map); - Map expected = Collections.emptyMap(); - - assertEquals(expected, actual); - } - -} \ No newline at end of file diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml new file mode 100644 index 0000000000..4ed6ed784d --- /dev/null +++ b/common/transport/transport-api/pom.xml @@ -0,0 +1,117 @@ + + + 4.0.0 + + org.thingsboard.common + 2.2.0-SNAPSHOT + transport + + org.thingsboard.common.transport + transport-api + jar + + Thingsboard Server Common Transport components + https://thingsboard.io + + + UTF-8 + ${basedir}/../../.. + + + + + org.thingsboard.common + data + + + org.thingsboard.common + message + + + org.thingsboard.common + queue + + + com.google.code.gson + gson + + + org.slf4j + slf4j-api + + + org.slf4j + log4j-over-slf4j + + + ch.qos.logback + logback-core + + + ch.qos.logback + logback-classic + + + junit + junit + test + + + org.mockito + mockito-all + test + + + org.springframework + spring-context + + + org.springframework.boot + spring-boot-starter-web + provided + + + com.google.guava + guava + + + org.apache.commons + commons-lang3 + + + com.google.protobuf + protobuf-java + + + com.github.vladimir-bukhtoyarov + bucket4j-core + + + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + + + + + diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java new file mode 100644 index 0000000000..09d1bcb08f --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java @@ -0,0 +1,38 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport; + +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionCloseNotificationProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcRequestMsg; + +/** + * Created by ashvayka on 04.10.18. + */ +public interface SessionMsgListener { + + void onGetAttributesResponse(GetAttributeResponseMsg getAttributesResponse); + + void onAttributeUpdate(AttributeUpdateNotificationMsg attributeUpdateNotification); + + void onRemoteSessionCloseCommand(SessionCloseNotificationProto sessionCloseNotification); + + void onToDeviceRpcRequest(ToDeviceRpcRequestMsg toDeviceRequest); + + void onToServerRpcResponse(ToServerRpcResponseMsg toServerResponse); +} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java similarity index 88% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java index 4cff643a5a..992492f7d6 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgProcessor.java @@ -16,12 +16,9 @@ package org.thingsboard.server.common.transport; import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; public interface SessionMsgProcessor { - void process(SessionAwareMsg msg); - void onDeviceAdded(Device device); } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java similarity index 77% rename from common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java index db6ff6f632..b59d49362b 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/aware/SessionAwareMsg.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportAdaptor.java @@ -13,12 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.aware; +package org.thingsboard.server.common.transport; -import org.thingsboard.server.common.data.id.SessionId; +import org.thingsboard.server.common.msg.session.SessionContext; -public interface SessionAwareMsg { - - SessionId getSessionId(); +public interface TransportAdaptor { } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java new file mode 100644 index 0000000000..58aaa78989 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java @@ -0,0 +1,64 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.kafka.TbNodeIdProvider; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Created by ashvayka on 15.10.18. + */ +@Slf4j +@Data +public class TransportContext { + + protected final ObjectMapper mapper = new ObjectMapper(); + + @Autowired + private TransportService transportService; + + @Autowired + private TbNodeIdProvider nodeIdProvider; + + @Getter + private ExecutorService executor; + + @PostConstruct + public void init() { + executor = Executors.newCachedThreadPool(); + } + + @PreDestroy + public void stop() { + if (executor != null) { + executor.shutdownNow(); + } + } + + public String getNodeId() { + return nodeIdProvider.getNodeId(); + } + +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java new file mode 100644 index 0000000000..8944e94a86 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -0,0 +1,74 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport; + +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.ToServerRpcRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToDeviceRpcResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToAttributeUpdatesMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SubscribeToRPCMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeRequestMsg; + +/** + * Created by ashvayka on 04.10.18. + */ +public interface TransportService { + + void process(ValidateDeviceTokenRequestMsg msg, + TransportServiceCallback callback); + + void process(ValidateDeviceX509CertRequestMsg msg, + TransportServiceCallback callback); + + void process(TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg msg, + TransportServiceCallback callback); + + boolean checkLimits(SessionInfoProto sessionInfo, TransportServiceCallback callback); + + void process(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback); + + void process(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback); + + void process(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback); + + void process(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback); + + void process(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback); + + void process(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback); + + void process(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback); + + void process(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback); + + void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscriptionInfoProto msg, TransportServiceCallback callback); + + void registerAsyncSession(SessionInfoProto sessionInfo, SessionMsgListener listener); + + void registerSyncSession(SessionInfoProto sessionInfo, SessionMsgListener listener, long timeout); + + void reportActivity(SessionInfoProto sessionInfo); + + void deregisterSession(SessionInfoProto sessionInfo); + +} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java similarity index 76% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java index f841749049..5443f7ed29 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/QuotaService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java @@ -13,13 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.transport.quota; +package org.thingsboard.server.common.transport; /** - * @author Vitaliy Paromskiy - * @version 1.0 + * Created by ashvayka on 04.10.18. */ -public interface QuotaService { +public interface TransportServiceCallback { + + void onSuccess(T msg); + void onError(Throwable e); - boolean isQuotaExceeded(String key); } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java new file mode 100644 index 0000000000..d52a896f10 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverter.java @@ -0,0 +1,459 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.adaptor; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSyntaxException; +import org.apache.commons.lang3.math.NumberUtils; +import org.springframework.util.StringUtils; +import org.thingsboard.server.common.data.kv.AttributeKey; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.msg.kv.AttributesKVMsg; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetAttributeResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueProto; +import org.thingsboard.server.gen.transport.TransportProtos.KeyValueType; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvListProto; +import org.thingsboard.server.gen.transport.TransportProtos.TsKvProto; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public class JsonConverter { + + private static final Gson GSON = new Gson(); + private static final String CAN_T_PARSE_VALUE = "Can't parse value: "; + private static final String DEVICE_PROPERTY = "device"; + + private static boolean isTypeCastEnabled = true; + + public static PostTelemetryMsg convertToTelemetryProto(JsonElement jsonObject) throws JsonSyntaxException { + long systemTs = System.currentTimeMillis(); + PostTelemetryMsg.Builder builder = PostTelemetryMsg.newBuilder(); + if (jsonObject.isJsonObject()) { + parseObject(builder, systemTs, jsonObject); + } else if (jsonObject.isJsonArray()) { + jsonObject.getAsJsonArray().forEach(je -> { + if (je.isJsonObject()) { + parseObject(builder, systemTs, je.getAsJsonObject()); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + je); + } + }); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + jsonObject); + } + return builder.build(); + } + + public static PostAttributeMsg convertToAttributesProto(JsonElement jsonObject) throws JsonSyntaxException { + if (jsonObject.isJsonObject()) { + PostAttributeMsg.Builder result = PostAttributeMsg.newBuilder(); + List keyValueList = parseProtoValues(jsonObject.getAsJsonObject()); + result.addAllKv(keyValueList); + return result.build(); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + jsonObject); + } + } + + public static JsonElement toJson(TransportProtos.ToDeviceRpcRequestMsg msg, boolean includeRequestId) { + JsonObject result = new JsonObject(); + if (includeRequestId) { + result.addProperty("id", msg.getRequestId()); + } + result.addProperty("method", msg.getMethodName()); + result.add("params", new JsonParser().parse(msg.getParams())); + return result; + } + + private static void parseObject(PostTelemetryMsg.Builder builder, long systemTs, JsonElement jsonObject) { + JsonObject jo = jsonObject.getAsJsonObject(); + if (jo.has("ts") && jo.has("values")) { + parseWithTs(builder, jo); + } else { + parseWithoutTs(builder, systemTs, jo); + } + } + + private static void parseWithoutTs(PostTelemetryMsg.Builder request, long systemTs, JsonObject jo) { + TsKvListProto.Builder builder = TsKvListProto.newBuilder(); + builder.setTs(systemTs); + builder.addAllKv(parseProtoValues(jo)); + request.addTsKvList(builder.build()); + } + + private static void parseWithTs(PostTelemetryMsg.Builder request, JsonObject jo) { + TsKvListProto.Builder builder = TsKvListProto.newBuilder(); + builder.setTs(jo.get("ts").getAsLong()); + builder.addAllKv(parseProtoValues(jo.get("values").getAsJsonObject())); + request.addTsKvList(builder.build()); + } + + private static List parseProtoValues(JsonObject valuesObject) { + List result = new ArrayList<>(); + for (Entry valueEntry : valuesObject.entrySet()) { + JsonElement element = valueEntry.getValue(); + if (element.isJsonPrimitive()) { + JsonPrimitive value = element.getAsJsonPrimitive(); + if (value.isString()) { + if(isTypeCastEnabled && NumberUtils.isParsable(value.getAsString())) { + try { + result.add(buildNumericKeyValueProto(value, valueEntry.getKey())); + } catch (RuntimeException th) { + result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.STRING_V) + .setStringV(value.getAsString()).build()); + } + } else { + result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.STRING_V) + .setStringV(value.getAsString()).build()); + } + } else if (value.isBoolean()) { + result.add(KeyValueProto.newBuilder().setKey(valueEntry.getKey()).setType(KeyValueType.BOOLEAN_V) + .setBoolV(value.getAsBoolean()).build()); + } else if (value.isNumber()) { + result.add(buildNumericKeyValueProto(value, valueEntry.getKey())); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + value); + } + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + element); + } + } + return result; + } + + private static KeyValueProto buildNumericKeyValueProto(JsonPrimitive value, String key) { + if (value.getAsString().contains(".")) { + return KeyValueProto.newBuilder() + .setKey(key) + .setType(KeyValueType.DOUBLE_V) + .setDoubleV(value.getAsDouble()) + .build(); + } else { + try { + long longValue = Long.parseLong(value.getAsString()); + return KeyValueProto.newBuilder().setKey(key).setType(KeyValueType.LONG_V) + .setLongV(longValue).build(); + } catch (NumberFormatException e) { + throw new JsonSyntaxException("Big integer values are not supported!"); + } + } + } + + public static TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(JsonElement json, int requestId) throws JsonSyntaxException { + JsonObject object = json.getAsJsonObject(); + return TransportProtos.ToServerRpcRequestMsg.newBuilder().setRequestId(requestId).setMethodName(object.get("method").getAsString()).setParams(GSON.toJson(object.get("params"))).build(); + } + + private static void parseNumericValue(List result, Entry valueEntry, JsonPrimitive value) { + if (value.getAsString().contains(".")) { + result.add(new DoubleDataEntry(valueEntry.getKey(), value.getAsDouble())); + } else { + try { + long longValue = Long.parseLong(value.getAsString()); + result.add(new LongDataEntry(valueEntry.getKey(), longValue)); + } catch (NumberFormatException e) { + throw new JsonSyntaxException("Big integer values are not supported!"); + } + } + } + + public static JsonObject toJson(GetAttributeResponseMsg payload) { + JsonObject result = new JsonObject(); + if (payload.getClientAttributeListCount() > 0) { + JsonObject attrObject = new JsonObject(); + payload.getClientAttributeListList().forEach(addToObjectFromProto(attrObject)); + result.add("client", attrObject); + } + if (payload.getSharedAttributeListCount() > 0) { + JsonObject attrObject = new JsonObject(); + payload.getSharedAttributeListList().forEach(addToObjectFromProto(attrObject)); + result.add("shared", attrObject); + } + if (payload.getDeletedAttributeKeysCount() > 0) { + JsonArray attrObject = new JsonArray(); + payload.getDeletedAttributeKeysList().forEach(attrObject::add); + result.add("deleted", attrObject); + } + return result; + } + + public static JsonElement toJson(AttributeUpdateNotificationMsg payload) { + JsonObject result = new JsonObject(); + if (payload.getSharedUpdatedCount() > 0) { + payload.getSharedUpdatedList().forEach(addToObjectFromProto(result)); + } + if (payload.getSharedDeletedCount() > 0) { + JsonArray attrObject = new JsonArray(); + payload.getSharedDeletedList().forEach(attrObject::add); + result.add("deleted", attrObject); + } + return result; + } + + public static JsonObject toJson(AttributesKVMsg payload, boolean asMap) { + JsonObject result = new JsonObject(); + if (asMap) { + if (!payload.getClientAttributes().isEmpty()) { + JsonObject attrObject = new JsonObject(); + payload.getClientAttributes().forEach(addToObject(attrObject)); + result.add("client", attrObject); + } + if (!payload.getSharedAttributes().isEmpty()) { + JsonObject attrObject = new JsonObject(); + payload.getSharedAttributes().forEach(addToObject(attrObject)); + result.add("shared", attrObject); + } + } else { + payload.getClientAttributes().forEach(addToObject(result)); + payload.getSharedAttributes().forEach(addToObject(result)); + } + if (!payload.getDeletedAttributes().isEmpty()) { + JsonArray attrObject = new JsonArray(); + payload.getDeletedAttributes().forEach(addToObject(attrObject)); + result.add("deleted", attrObject); + } + return result; + } + + public static JsonObject getJsonObjectForGateway(TransportProtos.GetAttributeResponseMsg responseMsg) { + JsonObject result = new JsonObject(); + result.addProperty("id", responseMsg.getRequestId()); + if (responseMsg.getClientAttributeListCount() > 0) { + addValues(result, responseMsg.getClientAttributeListList()); + } + if (responseMsg.getSharedAttributeListCount() > 0) { + addValues(result, responseMsg.getSharedAttributeListList()); + } + return result; + } + + public static JsonObject getJsonObjectForGateway(String deviceName, AttributeUpdateNotificationMsg notificationMsg) { + JsonObject result = new JsonObject(); + result.addProperty(DEVICE_PROPERTY, deviceName); + result.add("data", toJson(notificationMsg)); + return result; + } + + private static void addValues(JsonObject result, List kvList) { + if (kvList.size() == 1) { + addValueToJson(result, "value", kvList.get(0).getKv()); + } else { + JsonObject values; + if (result.has("values")) { + values = result.get("values").getAsJsonObject(); + } else { + values = new JsonObject(); + result.add("values", values); + } + kvList.forEach(value -> addValueToJson(values, value.getKv().getKey(), value.getKv())); + } + } + + private static void addValueToJson(JsonObject json, String name, TransportProtos.KeyValueProto entry) { + switch (entry.getType()) { + case BOOLEAN_V: + json.addProperty(name, entry.getBoolV()); + break; + case STRING_V: + json.addProperty(name, entry.getStringV()); + break; + case DOUBLE_V: + json.addProperty(name, entry.getDoubleV()); + break; + case LONG_V: + json.addProperty(name, entry.getLongV()); + break; + } + } + + private static Consumer addToObject(JsonArray result) { + return key -> result.add(key.getAttributeKey()); + } + + private static Consumer addToObjectFromProto(JsonObject result) { + return de -> { + JsonPrimitive value; + switch (de.getKv().getType()) { + case BOOLEAN_V: + value = new JsonPrimitive(de.getKv().getBoolV()); + break; + case DOUBLE_V: + value = new JsonPrimitive(de.getKv().getDoubleV()); + break; + case LONG_V: + value = new JsonPrimitive(de.getKv().getLongV()); + break; + case STRING_V: + value = new JsonPrimitive(de.getKv().getStringV()); + break; + default: + throw new IllegalArgumentException("Unsupported data type: " + de.getKv().getType()); + } + result.add(de.getKv().getKey(), value); + }; + } + + private static Consumer addToObject(JsonObject result) { + return de -> { + JsonPrimitive value; + switch (de.getDataType()) { + case BOOLEAN: + value = new JsonPrimitive(de.getBooleanValue().get()); + break; + case DOUBLE: + value = new JsonPrimitive(de.getDoubleValue().get()); + break; + case LONG: + value = new JsonPrimitive(de.getLongValue().get()); + break; + case STRING: + value = new JsonPrimitive(de.getStrValue().get()); + break; + default: + throw new IllegalArgumentException("Unsupported data type: " + de.getDataType()); + } + result.add(de.getKey(), value); + }; + } + + public static JsonElement toJson(TransportProtos.ToServerRpcResponseMsg msg) { + if (StringUtils.isEmpty(msg.getError())) { + return new JsonParser().parse(msg.getPayload()); + } else { + JsonObject errorMsg = new JsonObject(); + errorMsg.addProperty("error", msg.getError()); + return errorMsg; + } + } + + public static JsonElement toErrorJson(String errorMsg) { + JsonObject error = new JsonObject(); + error.addProperty("error", errorMsg); + return error; + } + + public static JsonElement toGatewayJson(String deviceName, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) { + JsonObject result = new JsonObject(); + result.addProperty(DEVICE_PROPERTY, deviceName); + result.add("data", JsonConverter.toJson(rpcRequest, true)); + return result; + } + + public static Set convertToAttributes(JsonElement element) { + Set result = new HashSet<>(); + long ts = System.currentTimeMillis(); + result.addAll(parseValues(element.getAsJsonObject()).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).collect(Collectors.toList())); + return result; + } + + private static List parseValues(JsonObject valuesObject) { + List result = new ArrayList<>(); + for (Entry valueEntry : valuesObject.entrySet()) { + JsonElement element = valueEntry.getValue(); + if (element.isJsonPrimitive()) { + JsonPrimitive value = element.getAsJsonPrimitive(); + if (value.isString()) { + if(isTypeCastEnabled && NumberUtils.isParsable(value.getAsString())) { + try { + parseNumericValue(result, valueEntry, value); + } catch (RuntimeException th) { + result.add(new StringDataEntry(valueEntry.getKey(), value.getAsString())); + } + } else { + result.add(new StringDataEntry(valueEntry.getKey(), value.getAsString())); + } + } else if (value.isBoolean()) { + result.add(new BooleanDataEntry(valueEntry.getKey(), value.getAsBoolean())); + } else if (value.isNumber()) { + parseNumericValue(result, valueEntry, value); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + value); + } + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + element); + } + } + return result; + } + + public static Map> convertToTelemetry(JsonElement jsonObject, long systemTs) throws JsonSyntaxException { + Map> result = new HashMap<>(); + if (jsonObject.isJsonObject()) { + parseObject(result, systemTs, jsonObject); + } else if (jsonObject.isJsonArray()) { + jsonObject.getAsJsonArray().forEach(je -> { + if (je.isJsonObject()) { + parseObject(result, systemTs, je.getAsJsonObject()); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + je); + } + }); + } else { + throw new JsonSyntaxException(CAN_T_PARSE_VALUE + jsonObject); + } + return result; + } + + private static void parseObject(Map> result, long systemTs, JsonElement jsonObject) { + JsonObject jo = jsonObject.getAsJsonObject(); + if (jo.has("ts") && jo.has("values")) { + parseWithTs(result, jo); + } else { + parseWithoutTs(result, systemTs, jo); + } + } + + private static void parseWithoutTs(Map> result, long systemTs, JsonObject jo) { + for (KvEntry entry : parseValues(jo)) { + result.computeIfAbsent(systemTs, tmp -> new ArrayList<>()).add(entry); + } + } + + public static void parseWithTs(Map> result, JsonObject jo) { + long ts = jo.get("ts").getAsLong(); + JsonObject valuesObject = jo.get("values").getAsJsonObject(); + for (KvEntry entry : parseValues(valuesObject)) { + result.computeIfAbsent(ts, tmp -> new ArrayList<>()).add(entry); + } + } + + public static void setTypeCastEnabled(boolean enabled) { + isTypeCastEnabled = enabled; + } +} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java similarity index 59% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java index eeef9247e6..ac3f4dc580 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/host/HostRequestLimitPolicy.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/JsonConverterConfig.java @@ -13,21 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.transport.quota.host; +package org.thingsboard.server.common.transport.adaptor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.transport.quota.RequestLimitPolicy; +import org.springframework.context.annotation.Configuration; -/** - * @author Vitaliy Paromskiy - * @version 1.0 - */ -@Component -public class HostRequestLimitPolicy extends RequestLimitPolicy { +@Configuration +@Slf4j +public class JsonConverterConfig { - public HostRequestLimitPolicy(@Value("${quota.host.limit}") long limit) { - super(limit); + @Value("${transport.json.type_cast_enabled:true}") + public void setIsJsonTypeCastEnabled(boolean jsonTypeCastEnabled) { + JsonConverter.setTypeCastEnabled(jsonTypeCastEnabled); + log.info("JSON type cast enabled = {}", jsonTypeCastEnabled); } - } diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthResult.java diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java similarity index 100% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/auth/DeviceAuthService.java diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java new file mode 100644 index 0000000000..fad19541b4 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/AbstractTransportService.java @@ -0,0 +1,290 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * Created by ashvayka on 17.10.18. + */ +@Slf4j +public abstract class AbstractTransportService implements TransportService { + + @Value("${transport.rate_limits.enabled}") + private boolean rateLimitEnabled; + @Value("${transport.rate_limits.tenant}") + private String perTenantLimitsConf; + @Value("${transport.rate_limits.tenant}") + private String perDevicesLimitsConf; + @Value("${transport.sessions.inactivity_timeout}") + private long sessionInactivityTimeout; + @Value("${transport.sessions.report_timeout}") + private long sessionReportTimeout; + + protected ScheduledExecutorService schedulerExecutor; + protected ExecutorService transportCallbackExecutor; + + private ConcurrentMap sessions = new ConcurrentHashMap<>(); + + //TODO: Implement cleanup of this maps. + private ConcurrentMap perTenantLimits = new ConcurrentHashMap<>(); + private ConcurrentMap perDeviceLimits = new ConcurrentHashMap<>(); + + @Override + public void registerAsyncSession(TransportProtos.SessionInfoProto sessionInfo, SessionMsgListener listener) { + sessions.putIfAbsent(toId(sessionInfo), new SessionMetaData(sessionInfo, TransportProtos.SessionType.ASYNC, listener)); + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionEventMsg msg, TransportServiceCallback callback) { + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostTelemetryMsg msg, TransportServiceCallback callback) { + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostAttributeMsg msg, TransportServiceCallback callback) { + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.GetAttributeRequestMsg msg, TransportServiceCallback callback) { + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { + if (checkLimits(sessionInfo, callback)) { + SessionMetaData sessionMetaData = reportActivityInternal(sessionInfo); + sessionMetaData.setSubscribedToAttributes(!msg.getUnsubscribe()); + doProcess(sessionInfo, msg, callback); + } + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToRPCMsg msg, TransportServiceCallback callback) { + if (checkLimits(sessionInfo, callback)) { + SessionMetaData sessionMetaData = reportActivityInternal(sessionInfo); + sessionMetaData.setSubscribedToRPC(!msg.getUnsubscribe()); + doProcess(sessionInfo, msg, callback); + } + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } + } + + @Override + public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg msg, TransportServiceCallback callback) { + if (checkLimits(sessionInfo, callback)) { + reportActivityInternal(sessionInfo); + doProcess(sessionInfo, msg, callback); + } + } + + @Override + public void reportActivity(TransportProtos.SessionInfoProto sessionInfo) { + reportActivityInternal(sessionInfo); + } + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionEventMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostTelemetryMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.PostAttributeMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.GetAttributeRequestMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SubscribeToRPCMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToDeviceRpcResponseMsg msg, TransportServiceCallback callback); + + protected abstract void doProcess(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.ToServerRpcRequestMsg msg, TransportServiceCallback callback); + + private SessionMetaData reportActivityInternal(TransportProtos.SessionInfoProto sessionInfo) { + UUID sessionId = toId(sessionInfo); + SessionMetaData sessionMetaData = sessions.get(sessionId); + if (sessionMetaData != null) { + sessionMetaData.updateLastActivityTime(); + } + return sessionMetaData; + } + + private void checkInactivityAndReportActivity() { + long expTime = System.currentTimeMillis() - sessionInactivityTimeout; + sessions.forEach((uuid, sessionMD) -> { + if (sessionMD.getLastActivityTime() < expTime) { + if (log.isDebugEnabled()) { + log.debug("[{}] Session has expired due to last activity time: {}", toId(sessionMD.getSessionInfo()), sessionMD.getLastActivityTime()); + } + process(sessionMD.getSessionInfo(), getSessionEventMsg(TransportProtos.SessionEvent.CLOSED), null); + sessions.remove(uuid); + sessionMD.getListener().onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto.getDefaultInstance()); + } else { + process(sessionMD.getSessionInfo(), TransportProtos.SubscriptionInfoProto.newBuilder() + .setAttributeSubscription(sessionMD.isSubscribedToAttributes()) + .setRpcSubscription(sessionMD.isSubscribedToRPC()) + .setLastActivityTime(sessionMD.getLastActivityTime()).build(), null); + } + }); + } + + @Override + public void registerSyncSession(TransportProtos.SessionInfoProto sessionInfo, SessionMsgListener listener, long timeout) { + sessions.putIfAbsent(toId(sessionInfo), new SessionMetaData(sessionInfo, TransportProtos.SessionType.SYNC, listener)); + schedulerExecutor.schedule(() -> { + listener.onRemoteSessionCloseCommand(TransportProtos.SessionCloseNotificationProto.getDefaultInstance()); + deregisterSession(sessionInfo); + }, timeout, TimeUnit.MILLISECONDS); + } + + @Override + public void deregisterSession(TransportProtos.SessionInfoProto sessionInfo) { + sessions.remove(toId(sessionInfo)); + } + + @Override + public boolean checkLimits(TransportProtos.SessionInfoProto sessionInfo, TransportServiceCallback callback) { + if (!rateLimitEnabled) { + return true; + } + TenantId tenantId = new TenantId(new UUID(sessionInfo.getTenantIdMSB(), sessionInfo.getTenantIdLSB())); + TbTransportRateLimits rateLimits = perTenantLimits.computeIfAbsent(tenantId, id -> new TbTransportRateLimits(perTenantLimitsConf)); + if (!rateLimits.tryConsume()) { + if (callback != null) { + callback.onError(new TbRateLimitsException(EntityType.TENANT)); + } + return false; + } + DeviceId deviceId = new DeviceId(new UUID(sessionInfo.getDeviceIdMSB(), sessionInfo.getDeviceIdLSB())); + rateLimits = perDeviceLimits.computeIfAbsent(deviceId, id -> new TbTransportRateLimits(perDevicesLimitsConf)); + if (!rateLimits.tryConsume()) { + if (callback != null) { + callback.onError(new TbRateLimitsException(EntityType.DEVICE)); + } + return false; + } + return true; + } + + protected void processToTransportMsg(TransportProtos.DeviceActorToTransportMsg toSessionMsg) { + UUID sessionId = new UUID(toSessionMsg.getSessionIdMSB(), toSessionMsg.getSessionIdLSB()); + SessionMetaData md = sessions.get(sessionId); + if (md != null) { + SessionMsgListener listener = md.getListener(); + transportCallbackExecutor.submit(() -> { + if (toSessionMsg.hasGetAttributesResponse()) { + listener.onGetAttributesResponse(toSessionMsg.getGetAttributesResponse()); + } + if (toSessionMsg.hasAttributeUpdateNotification()) { + listener.onAttributeUpdate(toSessionMsg.getAttributeUpdateNotification()); + } + if (toSessionMsg.hasSessionCloseNotification()) { + listener.onRemoteSessionCloseCommand(toSessionMsg.getSessionCloseNotification()); + } + if (toSessionMsg.hasToDeviceRequest()) { + listener.onToDeviceRpcRequest(toSessionMsg.getToDeviceRequest()); + } + if (toSessionMsg.hasToServerResponse()) { + listener.onToServerRpcResponse(toSessionMsg.getToServerResponse()); + } + }); + if (md.getSessionType() == TransportProtos.SessionType.SYNC) { + deregisterSession(md.getSessionInfo()); + } + } else { + //TODO: should we notify the device actor about missed session? + log.debug("[{}] Missing session.", sessionId); + } + } + + private UUID toId(TransportProtos.SessionInfoProto sessionInfo) { + return new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB()); + } + + String getRoutingKey(TransportProtos.SessionInfoProto sessionInfo) { + return new UUID(sessionInfo.getDeviceIdMSB(), sessionInfo.getDeviceIdLSB()).toString(); + } + + public void init() { + if (rateLimitEnabled) { + //Just checking the configuration parameters + new TbTransportRateLimits(perTenantLimitsConf); + new TbTransportRateLimits(perDevicesLimitsConf); + } + this.schedulerExecutor = Executors.newSingleThreadScheduledExecutor(); + this.transportCallbackExecutor = new ThreadPoolExecutor(0, 20, 60L, TimeUnit.SECONDS, new SynchronousQueue<>()); + this.schedulerExecutor.scheduleAtFixedRate(this::checkInactivityAndReportActivity, sessionReportTimeout, sessionReportTimeout, TimeUnit.MILLISECONDS); + } + + public void destroy() { + if (rateLimitEnabled) { + perTenantLimits.clear(); + perDeviceLimits.clear(); + } + if (schedulerExecutor != null) { + schedulerExecutor.shutdownNow(); + } + if (transportCallbackExecutor != null) { + transportCallbackExecutor.shutdownNow(); + } + } + + public static TransportProtos.SessionEventMsg getSessionEventMsg(TransportProtos.SessionEvent event) { + return TransportProtos.SessionEventMsg.newBuilder() + .setSessionType(TransportProtos.SessionType.ASYNC) + .setEvent(event).build(); + } +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java new file mode 100644 index 0000000000..4b11bf58c9 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/RemoteTransportService.java @@ -0,0 +1,324 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.admin.CreateTopicsResult; +import org.apache.kafka.clients.admin.NewTopic; +import org.apache.kafka.clients.consumer.ConsumerRecords; +import org.apache.kafka.clients.producer.Callback; +import org.apache.kafka.clients.producer.RecordMetadata; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.TransportServiceCallback; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.gen.transport.TransportProtos.*; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.GetOrCreateDeviceFromGatewayResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; +import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionEventMsg; +import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceTokenRequestMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceX509CertRequestMsg; +import org.thingsboard.server.kafka.*; + +import javax.annotation.PostConstruct; +import javax.annotation.PreDestroy; +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Created by ashvayka on 05.10.18. + */ +@ConditionalOnExpression("'${transport.type:null}'=='null'") +@Service +@Slf4j +public class RemoteTransportService extends AbstractTransportService { + + @Value("${kafka.rule_engine.topic}") + private String ruleEngineTopic; + @Value("${kafka.notifications.topic}") + private String notificationsTopic; + @Value("${kafka.notifications.poll_interval}") + private int notificationsPollDuration; + @Value("${kafka.notifications.auto_commit_interval}") + private int notificationsAutoCommitInterval; + @Value("${kafka.transport_api.requests_topic}") + private String transportApiRequestsTopic; + @Value("${kafka.transport_api.responses_topic}") + private String transportApiResponsesTopic; + @Value("${kafka.transport_api.max_pending_requests}") + private long maxPendingRequests; + @Value("${kafka.transport_api.max_requests_timeout}") + private long maxRequestsTimeout; + @Value("${kafka.transport_api.response_poll_interval}") + private int responsePollDuration; + @Value("${kafka.transport_api.response_auto_commit_interval}") + private int autoCommitInterval; + + @Autowired + private TbKafkaSettings kafkaSettings; + //We use this to get the node id. We should replace this with a component that provides the node id. + @Autowired + private TbNodeIdProvider nodeIdProvider; + + private TbKafkaRequestTemplate transportApiTemplate; + private TBKafkaProducerTemplate ruleEngineProducer; + private TBKafkaConsumerTemplate mainConsumer; + + private ExecutorService mainConsumerExecutor = Executors.newSingleThreadExecutor(); + + private volatile boolean stopped = false; + + @PostConstruct + public void init() { + super.init(); + + TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder requestBuilder = TBKafkaProducerTemplate.builder(); + requestBuilder.settings(kafkaSettings); + requestBuilder.defaultTopic(transportApiRequestsTopic); + requestBuilder.encoder(new TransportApiRequestEncoder()); + + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder responseBuilder = TBKafkaConsumerTemplate.builder(); + responseBuilder.settings(kafkaSettings); + responseBuilder.topic(transportApiResponsesTopic + "." + nodeIdProvider.getNodeId()); + responseBuilder.clientId("transport-api-client-" + nodeIdProvider.getNodeId()); + responseBuilder.groupId("transport-api-client"); + responseBuilder.autoCommit(true); + responseBuilder.autoCommitIntervalMs(autoCommitInterval); + responseBuilder.decoder(new TransportApiResponseDecoder()); + + TbKafkaRequestTemplate.TbKafkaRequestTemplateBuilder + builder = TbKafkaRequestTemplate.builder(); + builder.requestTemplate(requestBuilder.build()); + builder.responseTemplate(responseBuilder.build()); + builder.maxPendingRequests(maxPendingRequests); + builder.maxRequestTimeout(maxRequestsTimeout); + builder.pollInterval(responsePollDuration); + transportApiTemplate = builder.build(); + transportApiTemplate.init(); + + TBKafkaProducerTemplate.TBKafkaProducerTemplateBuilder ruleEngineProducerBuilder = TBKafkaProducerTemplate.builder(); + ruleEngineProducerBuilder.settings(kafkaSettings); + ruleEngineProducerBuilder.defaultTopic(ruleEngineTopic); + ruleEngineProducerBuilder.encoder(new ToRuleEngineMsgEncoder()); + ruleEngineProducer = ruleEngineProducerBuilder.build(); + ruleEngineProducer.init(); + + String notificationsTopicName = notificationsTopic + "." + nodeIdProvider.getNodeId(); + + try { + TBKafkaAdmin admin = new TBKafkaAdmin(kafkaSettings); + CreateTopicsResult result = admin.createTopic(new NewTopic(notificationsTopicName, 1, (short) 1)); + result.all().get(); + } catch (Exception e) { + log.trace("Failed to create topic: {}", e.getMessage(), e); + } + + TBKafkaConsumerTemplate.TBKafkaConsumerTemplateBuilder mainConsumerBuilder = TBKafkaConsumerTemplate.builder(); + mainConsumerBuilder.settings(kafkaSettings); + mainConsumerBuilder.topic(notificationsTopicName); + mainConsumerBuilder.clientId("transport-" + nodeIdProvider.getNodeId()); + mainConsumerBuilder.groupId("transport"); + mainConsumerBuilder.autoCommit(true); + mainConsumerBuilder.autoCommitIntervalMs(notificationsAutoCommitInterval); + mainConsumerBuilder.decoder(new ToTransportMsgResponseDecoder()); + mainConsumer = mainConsumerBuilder.build(); + mainConsumer.subscribe(); + + mainConsumerExecutor.execute(() -> { + while (!stopped) { + try { + ConsumerRecords records = mainConsumer.poll(Duration.ofMillis(notificationsPollDuration)); + records.forEach(record -> { + try { + ToTransportMsg toTransportMsg = mainConsumer.decode(record); + if (toTransportMsg.hasToDeviceSessionMsg()) { + processToTransportMsg(toTransportMsg.getToDeviceSessionMsg()); + } + } catch (Throwable e) { + log.warn("Failed to process the notification.", e); + } + }); + } catch (Exception e) { + log.warn("Failed to obtain messages from queue.", e); + try { + Thread.sleep(notificationsPollDuration); + } catch (InterruptedException e2) { + log.trace("Failed to wait until the server has capacity to handle new requests", e2); + } + } + } + }); + } + + @PreDestroy + public void destroy() { + super.destroy(); + stopped = true; + if (transportApiTemplate != null) { + transportApiTemplate.stop(); + } + if (mainConsumer != null) { + mainConsumer.unsubscribe(); + } + if (mainConsumerExecutor != null) { + mainConsumerExecutor.shutdownNow(); + } + } + + @Override + public void process(ValidateDeviceTokenRequestMsg msg, TransportServiceCallback callback) { + AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getToken(), + TransportApiRequestMsg.newBuilder().setValidateTokenRequestMsg(msg).build()), + response -> callback.onSuccess(response.getValidateTokenResponseMsg()), callback::onError, transportCallbackExecutor); + } + + @Override + public void process(ValidateDeviceX509CertRequestMsg msg, TransportServiceCallback callback) { + AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getHash(), + TransportApiRequestMsg.newBuilder().setValidateX509CertRequestMsg(msg).build()), + response -> callback.onSuccess(response.getValidateTokenResponseMsg()), callback::onError, transportCallbackExecutor); + } + + @Override + public void process(GetOrCreateDeviceFromGatewayRequestMsg msg, TransportServiceCallback callback) { + AsyncCallbackTemplate.withCallback(transportApiTemplate.post(msg.getDeviceName(), + TransportApiRequestMsg.newBuilder().setGetOrCreateDeviceRequestMsg(msg).build()), + response -> callback.onSuccess(response.getGetOrCreateDeviceResponseMsg()), callback::onError, transportCallbackExecutor); + } + + @Override + public void process(SessionInfoProto sessionInfo, SubscriptionInfoProto msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscriptionInfo(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, SessionEventMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSessionEvent(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, PostTelemetryMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setPostTelemetry(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, PostAttributeMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setPostAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, GetAttributeRequestMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setGetAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, SubscribeToAttributeUpdatesMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscribeToAttributes(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, SubscribeToRPCMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setSubscribeToRPC(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, ToDeviceRpcResponseMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setToDeviceRPCCallResponse(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + @Override + protected void doProcess(SessionInfoProto sessionInfo, ToServerRpcRequestMsg msg, TransportServiceCallback callback) { + ToRuleEngineMsg toRuleEngineMsg = ToRuleEngineMsg.newBuilder().setToDeviceActorMsg( + TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) + .setToServerRPCCallRequest(msg).build() + ).build(); + send(sessionInfo, toRuleEngineMsg, callback); + } + + private static class TransportCallbackAdaptor implements Callback { + private final TransportServiceCallback callback; + + TransportCallbackAdaptor(TransportServiceCallback callback) { + this.callback = callback; + } + + @Override + public void onCompletion(RecordMetadata metadata, Exception exception) { + if (exception == null) { + if (callback != null) { + callback.onSuccess(null); + } + } else { + if (callback != null) { + callback.onError(exception); + } + } + } + } + + private void send(SessionInfoProto sessionInfo, ToRuleEngineMsg toRuleEngineMsg, TransportServiceCallback callback) { + ruleEngineProducer.send(getRoutingKey(sessionInfo), toRuleEngineMsg, new TransportCallbackAdaptor(callback)); + } +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java new file mode 100644 index 0000000000..8642e93a97 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import lombok.Data; +import org.thingsboard.server.common.transport.SessionMsgListener; +import org.thingsboard.server.gen.transport.TransportProtos; + +/** + * Created by ashvayka on 15.10.18. + */ +@Data +class SessionMetaData { + + private final TransportProtos.SessionInfoProto sessionInfo; + private final TransportProtos.SessionType sessionType; + private final SessionMsgListener listener; + + private volatile long lastActivityTime; + private volatile boolean subscribedToAttributes; + private volatile boolean subscribedToRPC; + + SessionMetaData(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionType sessionType, SessionMsgListener listener) { + this.sessionInfo = sessionInfo; + this.sessionType = sessionType; + this.listener = listener; + this.lastActivityTime = System.currentTimeMillis(); + } + + void updateLastActivityTime() { + this.lastActivityTime = System.currentTimeMillis(); + } + +} diff --git a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbRateLimitsException.java similarity index 64% rename from common/transport/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbRateLimitsException.java index 0ff1230b1c..9d2669da99 100644 --- a/common/transport/src/main/java/org/thingsboard/server/common/transport/quota/RequestLimitPolicy.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbRateLimitsException.java @@ -13,18 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.transport.quota; +package org.thingsboard.server.common.transport.service; +import org.thingsboard.server.common.data.EntityType; -public abstract class RequestLimitPolicy { - - private final long limit; - - public RequestLimitPolicy(long limit) { - this.limit = limit; - } +/** + * Created by ashvayka on 22.10.18. + */ +public class TbRateLimitsException extends Exception { + private final EntityType entityType; - public boolean isValid(long currentValue) { - return currentValue <= limit; + TbRateLimitsException(EntityType entityType) { + this.entityType = entityType; } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbTransportRateLimits.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbTransportRateLimits.java new file mode 100644 index 0000000000..d598734a5a --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TbTransportRateLimits.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.Bucket4j; +import io.github.bucket4j.local.LocalBucket; +import io.github.bucket4j.local.LocalBucketBuilder; + +import java.time.Duration; + +/** + * Created by ashvayka on 22.10.18. + */ +class TbTransportRateLimits { + private final LocalBucket bucket; + + public TbTransportRateLimits(String limitsConfiguration) { + LocalBucketBuilder builder = Bucket4j.builder(); + boolean initialized = false; + for (String limitSrc : limitsConfiguration.split(",")) { + long capacity = Long.parseLong(limitSrc.split(":")[0]); + long duration = Long.parseLong(limitSrc.split(":")[1]); + builder.addLimit(Bandwidth.simple(capacity, Duration.ofSeconds(duration))); + initialized = true; + } + if (initialized) { + bucket = builder.build(); + } else { + throw new IllegalArgumentException("Failed to parse rate limits configuration: " + limitsConfiguration); + } + + + } + + boolean tryConsume() { + return bucket.tryConsume(1); + } + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseMsg.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java similarity index 61% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseMsg.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java index 6d3ad5e8be..2842d7ef66 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/SessionCloseMsg.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java @@ -13,18 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; +package org.thingsboard.server.common.transport.service; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; +import org.thingsboard.server.kafka.TbKafkaEncoder; /** - * @author Andrew Shvayka + * Created by ashvayka on 05.10.18. */ -public class SessionCloseMsg implements FromDeviceMsg { +public class ToRuleEngineMsgEncoder implements TbKafkaEncoder { @Override - public SessionMsgType getMsgType() { - return SessionMsgType.SESSION_CLOSE; + public byte[] encode(ToRuleEngineMsg value) { + return value.toByteArray(); } } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUnsubscribeMsg.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java similarity index 59% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUnsubscribeMsg.java rename to common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java index c98ad2ae26..a905f76ace 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/AttributesUnsubscribeMsg.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java @@ -13,18 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; +package org.thingsboard.server.common.transport.service; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.gen.transport.TransportProtos.ToTransportMsg; +import org.thingsboard.server.kafka.TbKafkaDecoder; + +import java.io.IOException; /** - * @author Andrew Shvayka + * Created by ashvayka on 05.10.18. */ -public class AttributesUnsubscribeMsg implements FromDeviceMsg { +public class ToTransportMsgResponseDecoder implements TbKafkaDecoder { @Override - public SessionMsgType getMsgType() { - return SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST; + public ToTransportMsg decode(byte[] data) throws IOException { + return ToTransportMsg.parseFrom(data); } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java new file mode 100644 index 0000000000..a8f27aad8f --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java @@ -0,0 +1,29 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; +import org.thingsboard.server.kafka.TbKafkaEncoder; + +/** + * Created by ashvayka on 05.10.18. + */ +public class TransportApiRequestEncoder implements TbKafkaEncoder { + @Override + public byte[] encode(TransportApiRequestMsg value) { + return value.toByteArray(); + } +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java new file mode 100644 index 0000000000..3b5c9ce579 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import org.thingsboard.server.gen.transport.TransportProtos.TransportApiResponseMsg; +import org.thingsboard.server.kafka.TbKafkaDecoder; + +import java.io.IOException; + +/** + * Created by ashvayka on 05.10.18. + */ +public class TransportApiResponseDecoder implements TbKafkaDecoder { + @Override + public TransportApiResponseMsg decode(byte[] data) throws IOException { + return TransportApiResponseMsg.parseFrom(data); + } +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java new file mode 100644 index 0000000000..93c0f41ddd --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.session; + +import lombok.Data; +import lombok.Getter; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.msg.session.SessionContext; +import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; + +import java.util.UUID; + +/** + * @author Andrew Shvayka + */ +@Data +public abstract class DeviceAwareSessionContext implements SessionContext { + + @Getter + protected final UUID sessionId; + @Getter + private volatile DeviceId deviceId; + @Getter + private volatile DeviceInfoProto deviceInfo; + + public DeviceId getDeviceId() { + return deviceId; + } + + public void setDeviceInfo(DeviceInfoProto deviceInfo) { + this.deviceInfo = deviceInfo; + this.deviceId = new DeviceId(new UUID(deviceInfo.getDeviceIdMSB(), deviceInfo.getDeviceIdLSB())); + } + + + public boolean isConnected() { + return deviceInfo != null; + } +} diff --git a/common/transport/transport-api/src/main/proto/transport.proto b/common/transport/transport-api/src/main/proto/transport.proto new file mode 100644 index 0000000000..ff740d4ce4 --- /dev/null +++ b/common/transport/transport-api/src/main/proto/transport.proto @@ -0,0 +1,234 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +syntax = "proto3"; +package transport; + +option java_package = "org.thingsboard.server.gen.transport"; +option java_outer_classname = "TransportProtos"; + +/** + * Data Structures; + */ +message SessionInfoProto { + string nodeId = 1; + int64 sessionIdMSB = 2; + int64 sessionIdLSB = 3; + int64 tenantIdMSB = 4; + int64 tenantIdLSB = 5; + int64 deviceIdMSB = 6; + int64 deviceIdLSB = 7; +} + +enum SessionEvent { + OPEN = 0; + CLOSED = 1; +} + +enum SessionType { + SYNC = 0; + ASYNC = 1; +} + +enum KeyValueType { + BOOLEAN_V = 0; + LONG_V = 1; + DOUBLE_V = 2; + STRING_V = 3; +} + +message KeyValueProto { + string key = 1; + KeyValueType type = 2; + bool bool_v = 3; + int64 long_v = 4; + double double_v = 5; + string string_v = 6; +} + +message TsKvProto { + int64 ts = 1; + KeyValueProto kv = 2; +} + +message TsKvListProto { + int64 ts = 1; + repeated KeyValueProto kv = 2; +} + +message DeviceInfoProto { + int64 tenantIdMSB = 1; + int64 tenantIdLSB = 2; + int64 deviceIdMSB = 3; + int64 deviceIdLSB = 4; + string deviceName = 5; + string deviceType = 6; + string additionalInfo = 7; +} + +/** + * Messages that use Data Structures; + */ +message SessionEventMsg { + SessionType sessionType = 1; + SessionEvent event = 2; +} + +message PostTelemetryMsg { + repeated TsKvListProto tsKvList = 1; +} + +message PostAttributeMsg { + repeated KeyValueProto kv = 1; +} + +message GetAttributeRequestMsg { + int32 requestId = 1; + repeated string clientAttributeNames = 2; + repeated string sharedAttributeNames = 3; +} + +message GetAttributeResponseMsg { + int32 requestId = 1; + repeated TsKvProto clientAttributeList = 2; + repeated TsKvProto sharedAttributeList = 3; + repeated string deletedAttributeKeys = 4; + string error = 5; +} + +message AttributeUpdateNotificationMsg { + repeated TsKvProto sharedUpdated = 1; + repeated string sharedDeleted = 2; +} + +message ValidateDeviceTokenRequestMsg { + string token = 1; +} + +message ValidateDeviceX509CertRequestMsg { + string hash = 1; +} + +message ValidateDeviceCredentialsResponseMsg { + DeviceInfoProto deviceInfo = 1; + string credentialsBody = 2; +} + +message GetOrCreateDeviceFromGatewayRequestMsg { + int64 gatewayIdMSB = 1; + int64 gatewayIdLSB = 2; + string deviceName = 3; + string deviceType = 4; +} + +message GetOrCreateDeviceFromGatewayResponseMsg { + DeviceInfoProto deviceInfo = 1; +} + +message SessionCloseNotificationProto { + string message = 1; +} + +message SubscribeToAttributeUpdatesMsg { + bool unsubscribe = 1; +} + +message SubscribeToRPCMsg { + bool unsubscribe = 1; +} + +message ToDeviceRpcRequestMsg { + int32 requestId = 1; + string methodName = 2; + string params = 3; +} + +message ToDeviceRpcResponseMsg { + int32 requestId = 1; + string payload = 2; +} + +message ToServerRpcRequestMsg { + int32 requestId = 1; + string methodName = 2; + string params = 3; +} + +message ToServerRpcResponseMsg { + int32 requestId = 1; + string payload = 2; + string error = 3; +} + +//Used to report session state to tb-node and persist this state in the cache on the tb-node level. +message SubscriptionInfoProto { + int64 lastActivityTime = 1; + bool attributeSubscription = 2; + bool rpcSubscription = 3; +} + +message SessionSubscriptionInfoProto { + SessionInfoProto sessionInfo = 1; + SubscriptionInfoProto subscriptionInfo = 2; +} + +message DeviceSessionsCacheEntry { + repeated SessionSubscriptionInfoProto sessions = 1; +} + +message TransportToDeviceActorMsg { + SessionInfoProto sessionInfo = 1; + SessionEventMsg sessionEvent = 2; + PostTelemetryMsg postTelemetry = 3; + PostAttributeMsg postAttributes = 4; + GetAttributeRequestMsg getAttributes = 5; + SubscribeToAttributeUpdatesMsg subscribeToAttributes = 6; + SubscribeToRPCMsg subscribeToRPC = 7; + ToDeviceRpcResponseMsg toDeviceRPCCallResponse = 8; + ToServerRpcRequestMsg toServerRPCCallRequest = 9; + SubscriptionInfoProto subscriptionInfo = 10; +} + +message DeviceActorToTransportMsg { + int64 sessionIdMSB = 1; + int64 sessionIdLSB = 2; + SessionCloseNotificationProto sessionCloseNotification = 3; + GetAttributeResponseMsg getAttributesResponse = 4; + AttributeUpdateNotificationMsg attributeUpdateNotification = 5; + ToDeviceRpcRequestMsg toDeviceRequest = 6; + ToServerRpcResponseMsg toServerResponse = 7; +} + +/** + * Main messages; + */ +message ToRuleEngineMsg { + TransportToDeviceActorMsg toDeviceActorMsg = 1; +} + +message ToTransportMsg { + DeviceActorToTransportMsg toDeviceSessionMsg = 1; +} + +message TransportApiRequestMsg { + ValidateDeviceTokenRequestMsg validateTokenRequestMsg = 1; + ValidateDeviceX509CertRequestMsg validateX509CertRequestMsg = 2; + GetOrCreateDeviceFromGatewayRequestMsg getOrCreateDeviceRequestMsg = 3; +} + +message TransportApiResponseMsg { + ValidateDeviceCredentialsResponseMsg validateTokenResponseMsg = 1; + GetOrCreateDeviceFromGatewayResponseMsg getOrCreateDeviceResponseMsg = 2; +} diff --git a/dao/pom.xml b/dao/pom.xml index b12ea421d5..275aee094d 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard dao diff --git a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java index 6c8b60914e..ebd056048b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/asset/BaseAssetService.java @@ -30,6 +30,7 @@ import org.springframework.util.StringUtils; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetSearchQuery; @@ -43,6 +44,7 @@ import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; @@ -52,6 +54,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.CacheConstants.ASSET_CACHE; @@ -76,6 +79,9 @@ public class BaseAssetService extends AbstractEntityService implements AssetServ @Autowired private CustomerDao customerDao; + @Autowired + private EntityViewService entityViewService; + @Autowired private CacheManager cacheManager; @@ -130,11 +136,21 @@ public class BaseAssetService extends AbstractEntityService implements AssetServ validateId(assetId, INCORRECT_ASSET_ID + assetId); deleteEntityRelations(assetId); - Cache cache = cacheManager.getCache(ASSET_CACHE); Asset asset = assetDao.findById(assetId.getId()); + try { + List entityViews = entityViewService.findEntityViewsByTenantIdAndEntityIdAsync(asset.getTenantId(), assetId).get(); + if (entityViews != null && !entityViews.isEmpty()) { + throw new DataValidationException("Can't delete asset that is assigned to entity views!"); + } + } catch (ExecutionException | InterruptedException e) { + log.error("Exception while finding entity views for assetId [{}]", assetId, e); + throw new RuntimeException("Exception while finding entity views for assetId [" + assetId + "]", e); + } + List list = new ArrayList<>(); list.add(asset.getTenantId()); list.add(asset.getName()); + Cache cache = cacheManager.getCache(ASSET_CACHE); cache.evict(list); assetDao.removeById(assetId.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/cache/CaffeineCacheConfiguration.java b/dao/src/main/java/org/thingsboard/server/dao/cache/CaffeineCacheConfiguration.java index 688dfb3f9f..fc2868e805 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cache/CaffeineCacheConfiguration.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cache/CaffeineCacheConfiguration.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.RemovalCause; import com.github.benmanes.caffeine.cache.Ticker; +import com.github.benmanes.caffeine.cache.Weigher; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -31,6 +32,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -64,8 +66,9 @@ public class CaffeineCacheConfiguration { private CaffeineCache buildCache(String name, CacheSpecs cacheSpec) { final Caffeine caffeineBuilder = Caffeine.newBuilder() + .weigher(collectionSafeWeigher()) + .maximumWeight(cacheSpec.getMaxSize()) .expireAfterWrite(cacheSpec.getTimeToLiveInMinutes(), TimeUnit.MINUTES) - .maximumSize(cacheSpec.getMaxSize()) .ticker(ticker()); return new CaffeineCache(name, caffeineBuilder.build()); } @@ -80,4 +83,12 @@ public class CaffeineCacheConfiguration { return new PreviousDeviceCredentialsIdKeyGenerator(); } + private Weigher collectionSafeWeigher() { + return (Weigher) (key, value) -> { + if(value instanceof Collection) { + return ((Collection) value).size(); + } + return 1; + }; + } } 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 567523228a..19409ad146 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 @@ -17,12 +17,12 @@ package org.thingsboard.server.dao.cassandra; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PostConstruct; @Component -@NoSqlDao +@NoSqlAnyDao public class CassandraCluster extends AbstractCassandraCluster { @Value("${cassandra.keyspace_name}") 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 02968078f1..247a204ee5 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 @@ -17,12 +17,12 @@ package org.thingsboard.server.dao.cassandra; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PostConstruct; @Component -@NoSqlDao +@NoSqlAnyDao @Profile("install") public class CassandraInstallCluster extends AbstractCassandraCluster { diff --git a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraQueryOptions.java b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraQueryOptions.java index 474cad7c6a..1f09342ca1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraQueryOptions.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraQueryOptions.java @@ -21,14 +21,14 @@ import lombok.Data; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PostConstruct; @Component @Configuration @Data -@NoSqlDao +@NoSqlAnyDao public class CassandraQueryOptions { @Value("${cassandra.query.default_fetch_size}") diff --git a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraSocketOptions.java b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraSocketOptions.java index 8171ccc07f..15263c8b34 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraSocketOptions.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cassandra/CassandraSocketOptions.java @@ -20,14 +20,14 @@ import lombok.Data; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlAnyDao; import javax.annotation.PostConstruct; @Component @Configuration @Data -@NoSqlDao +@NoSqlAnyDao public class CassandraSocketOptions { @Value("${cassandra.socket.connect_timeout}") diff --git a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java index 36b250d218..a9b8bfe181 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/customer/CustomerServiceImpl.java @@ -32,6 +32,7 @@ import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.DataValidator; @@ -69,6 +70,9 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom @Autowired private DeviceService deviceService; + @Autowired + private EntityViewService entityViewService; + @Autowired private DashboardService dashboardService; @@ -111,6 +115,7 @@ public class CustomerServiceImpl extends AbstractEntityService implements Custom throw new IncorrectParameterException("Unable to delete non-existent customer."); } dashboardService.unassignCustomerDashboards(customerId); + entityViewService.unassignCustomerEntityViews(customer.getTenantId(), customerId); assetService.unassignCustomerAssets(customer.getTenantId(), customerId); deviceService.unassignCustomerDevices(customer.getTenantId(), customerId); userService.deleteCustomerUsers(customer.getTenantId(), customerId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java index 4219b068f1..101bff2bdd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceCredentialsServiceImpl.java @@ -26,7 +26,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; -import org.thingsboard.server.dao.EncryptionUtil; +import org.thingsboard.server.common.msg.EncryptionUtil; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; @@ -62,15 +62,15 @@ public class DeviceCredentialsServiceImpl implements DeviceCredentialsService { @Override @CacheEvict(cacheNames = DEVICE_CREDENTIALS_CACHE, keyGenerator="previousDeviceCredentialsId", beforeInvocation = true) public DeviceCredentials updateDeviceCredentials(DeviceCredentials deviceCredentials) { - return saveOrUpdare(deviceCredentials); + return saveOrUpdate(deviceCredentials); } @Override public DeviceCredentials createDeviceCredentials(DeviceCredentials deviceCredentials) { - return saveOrUpdare(deviceCredentials); + return saveOrUpdate(deviceCredentials); } - private DeviceCredentials saveOrUpdare(DeviceCredentials deviceCredentials) { + private DeviceCredentials saveOrUpdate(DeviceCredentials deviceCredentials) { if (deviceCredentials.getCredentialsType() == DeviceCredentialsType.X509_CERTIFICATE) { formatCertData(deviceCredentials); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index 3930e3a94f..2be5ba4f41 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -31,6 +31,7 @@ import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntitySubtype; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.id.CustomerId; @@ -45,6 +46,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.dao.customer.CustomerDao; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; @@ -56,6 +58,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; import java.util.Optional; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.CacheConstants.DEVICE_CACHE; @@ -86,6 +89,9 @@ public class DeviceServiceImpl extends AbstractEntityService implements DeviceSe @Autowired private DeviceCredentialsService deviceCredentialsService; + @Autowired + private EntityViewService entityViewService; + @Autowired private CacheManager cacheManager; @@ -145,18 +151,31 @@ public class DeviceServiceImpl extends AbstractEntityService implements DeviceSe @Override public void deleteDevice(DeviceId deviceId) { log.trace("Executing deleteDevice [{}]", deviceId); - Cache cache = cacheManager.getCache(DEVICE_CACHE); validateId(deviceId, INCORRECT_DEVICE_ID + deviceId); + + Device device = deviceDao.findById(deviceId.getId()); + try { + List entityViews = entityViewService.findEntityViewsByTenantIdAndEntityIdAsync(device.getTenantId(), deviceId).get(); + if (entityViews != null && !entityViews.isEmpty()) { + throw new DataValidationException("Can't delete device that is assigned to entity views!"); + } + } catch (ExecutionException | InterruptedException e) { + log.error("Exception while finding entity views for deviceId [{}]", deviceId, e); + throw new RuntimeException("Exception while finding entity views for deviceId [" + deviceId + "]", e); + } + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(deviceId); if (deviceCredentials != null) { deviceCredentialsService.deleteDeviceCredentials(deviceCredentials); } deleteEntityRelations(deviceId); - Device device = deviceDao.findById(deviceId.getId()); + List list = new ArrayList<>(); list.add(device.getTenantId()); list.add(device.getName()); + Cache cache = cacheManager.getCache(DEVICE_CACHE); cache.evict(list); + deviceDao.removeById(deviceId.getId()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java index 1b67ca0486..33a26997ca 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/entity/BaseEntityService.java @@ -29,6 +29,7 @@ import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.dao.user.UserService; @@ -46,6 +47,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe @Autowired private DeviceService deviceService; + @Autowired + private EntityViewService entityViewService; + @Autowired private TenantService tenantService; @@ -81,6 +85,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe case DEVICE: hasName = deviceService.findDeviceByIdAsync(new DeviceId(entityId.getId())); break; + case ENTITY_VIEW: + hasName = entityViewService.findEntityViewByIdAsync(new EntityViewId(entityId.getId())); + break; case TENANT: hasName = tenantService.findTenantByIdAsync(new TenantId(entityId.getId())); break; diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java new file mode 100644 index 0000000000..6ddc18d463 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/CassandraEntityViewDao.java @@ -0,0 +1,185 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.entityview; + +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.ResultSetFuture; +import com.datastax.driver.core.Statement; +import com.datastax.driver.core.querybuilder.Select; +import com.datastax.driver.mapping.Result; +import com.google.common.base.Function; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.model.EntitySubtypeEntity; +import org.thingsboard.server.dao.model.nosql.EntityViewEntity; +import org.thingsboard.server.dao.nosql.CassandraAbstractSearchTextDao; +import org.thingsboard.server.dao.util.NoSqlDao; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; +import static com.datastax.driver.core.querybuilder.QueryBuilder.select; +import static org.thingsboard.server.dao.model.ModelConstants.CUSTOMER_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.DEVICE_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_ID_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_SUBTYPE_COLUMN_FAMILY_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_SUBTYPE_ENTITY_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_SUBTYPE_TENANT_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_TYPE_CF; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_CF; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_ENTITY_ID_CF; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_CF; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_BY_TENANT_BY_TYPE_AND_SEARCH_TEXT_CF; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_NAME_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.TENANT_ID_PROPERTY; + +/** + * Created by Victor Basanets on 9/06/2017. + */ +@Component +@Slf4j +@NoSqlDao +public class CassandraEntityViewDao extends CassandraAbstractSearchTextDao implements EntityViewDao { + + @Override + protected Class getColumnFamilyClass() { + return EntityViewEntity.class; + } + + @Override + protected String getColumnFamilyName() { + return ENTITY_VIEW_TABLE_FAMILY_NAME; + } + + @Override + public EntityView save(EntityView domain) { + EntityView savedEntityView = super.save(domain); + EntitySubtype entitySubtype = new EntitySubtype(savedEntityView.getTenantId(), EntityType.ENTITY_VIEW, savedEntityView.getType()); + EntitySubtypeEntity entitySubtypeEntity = new EntitySubtypeEntity(entitySubtype); + Statement saveStatement = cluster.getMapper(EntitySubtypeEntity.class).saveQuery(entitySubtypeEntity); + executeWrite(saveStatement); + return savedEntityView; + } + + @Override + public List findEntityViewsByTenantId(UUID tenantId, TextPageLink pageLink) { + log.debug("Try to find entity views by tenantId [{}] and pageLink [{}]", tenantId, pageLink); + List entityViewEntities = + findPageWithTextSearch(ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_CF, + Collections.singletonList(eq(TENANT_ID_PROPERTY, tenantId)), pageLink); + log.trace("Found entity views [{}] by tenantId [{}] and pageLink [{}]", + entityViewEntities, tenantId, pageLink); + return DaoUtil.convertDataList(entityViewEntities); + } + + @Override + public List findEntityViewsByTenantIdAndType(UUID tenantId, String type, TextPageLink pageLink) { + log.debug("Try to find entity views by tenantId [{}], type [{}] and pageLink [{}]", tenantId, type, pageLink); + List entityViewEntities = + findPageWithTextSearch(ENTITY_VIEW_BY_TENANT_BY_TYPE_AND_SEARCH_TEXT_CF, + Arrays.asList(eq(ENTITY_VIEW_TYPE_PROPERTY, type), + eq(TENANT_ID_PROPERTY, tenantId)), pageLink); + log.trace("Found entity views [{}] by tenantId [{}], type [{}] and pageLink [{}]", + entityViewEntities, tenantId, type, pageLink); + return DaoUtil.convertDataList(entityViewEntities); + } + + @Override + public Optional findEntityViewByTenantIdAndName(UUID tenantId, String name) { + Select.Where query = select().from(ENTITY_VIEW_BY_TENANT_AND_NAME).where(); + query.and(eq(ENTITY_VIEW_TENANT_ID_PROPERTY, tenantId)); + query.and(eq(ENTITY_VIEW_NAME_PROPERTY, name)); + return Optional.ofNullable(DaoUtil.getData(findOneByStatement(query))); + } + + @Override + public List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, UUID customerId, TextPageLink pageLink) { + log.debug("Try to find entity views by tenantId [{}], customerId[{}] and pageLink [{}]", + tenantId, customerId, pageLink); + List entityViewEntities = findPageWithTextSearch( + ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_CF, + Arrays.asList(eq(CUSTOMER_ID_PROPERTY, customerId), eq(TENANT_ID_PROPERTY, tenantId)), + pageLink); + log.trace("Found find entity views [{}] by tenantId [{}], customerId [{}] and pageLink [{}]", + entityViewEntities, tenantId, customerId, pageLink); + return DaoUtil.convertDataList(entityViewEntities); + } + + @Override + public List findEntityViewsByTenantIdAndCustomerIdAndType(UUID tenantId, UUID customerId, String type, TextPageLink pageLink) { + log.debug("Try to find entity views by tenantId [{}], customerId[{}], type [{}] and pageLink [{}]", + tenantId, customerId, type, pageLink); + List entityViewEntities = findPageWithTextSearch( + ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_TYPE_CF, + Arrays.asList(eq(DEVICE_TYPE_PROPERTY, type), eq(CUSTOMER_ID_PROPERTY, customerId), eq(TENANT_ID_PROPERTY, tenantId)), + pageLink); + log.trace("Found find entity views [{}] by tenantId [{}], customerId [{}], type [{}] and pageLink [{}]", + entityViewEntities, tenantId, customerId, type, pageLink); + return DaoUtil.convertDataList(entityViewEntities); + } + + @Override + public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) { + log.debug("Try to find entity views by tenantId [{}] and entityId [{}]", tenantId, entityId); + Select.Where query = select().from(ENTITY_VIEW_BY_TENANT_AND_ENTITY_ID_CF).where(); + query.and(eq(TENANT_ID_PROPERTY, tenantId)); + query.and(eq(ENTITY_ID_COLUMN, entityId)); + return findListByStatementAsync(query); + } + + @Override + public ListenableFuture> findTenantEntityViewTypesAsync(UUID tenantId) { + Select select = select().from(ENTITY_SUBTYPE_COLUMN_FAMILY_NAME); + Select.Where query = select.where(); + query.and(eq(ENTITY_SUBTYPE_TENANT_ID_PROPERTY, tenantId)); + query.and(eq(ENTITY_SUBTYPE_ENTITY_TYPE_PROPERTY, EntityType.ENTITY_VIEW)); + query.setConsistencyLevel(cluster.getDefaultReadConsistencyLevel()); + ResultSetFuture resultSetFuture = executeAsyncRead(query); + return Futures.transform(resultSetFuture, new Function>() { + @Nullable + @Override + public List apply(@Nullable ResultSet resultSet) { + Result result = cluster.getMapper(EntitySubtypeEntity.class).map(resultSet); + if (result != null) { + List entitySubtypes = new ArrayList<>(); + result.all().forEach((entitySubtypeEntity) -> + entitySubtypes.add(entitySubtypeEntity.toEntitySubtype()) + ); + return entitySubtypes; + } else { + return Collections.emptyList(); + } + } + }); + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java new file mode 100644 index 0000000000..8147a07a88 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewDao.java @@ -0,0 +1,105 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.entityview; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.dao.Dao; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Created by Victor Basanets on 8/28/2017. + */ +public interface EntityViewDao extends Dao { + + /** + * Save or update device object + * + * @param entityView the entity-view object + * @return saved entity-view object + */ + EntityView save(EntityView entityView); + + /** + * Find entity views by tenantId and page link. + * + * @param tenantId the tenantId + * @param pageLink the page link + * @return the list of entity view objects + */ + List findEntityViewsByTenantId(UUID tenantId, TextPageLink pageLink); + + /** + * Find entity views by tenantId, type and page link. + * + * @param tenantId the tenantId + * @param type the type + * @param pageLink the page link + * @return the list of entity view objects + */ + List findEntityViewsByTenantIdAndType(UUID tenantId, String type, TextPageLink pageLink); + + /** + * Find entity views by tenantId and entity view name. + * + * @param tenantId the tenantId + * @param name the entity view name + * @return the optional entity view object + */ + Optional findEntityViewByTenantIdAndName(UUID tenantId, String name); + + /** + * Find entity views by tenantId, customerId and page link. + * + * @param tenantId the tenantId + * @param customerId the customerId + * @param pageLink the page link + * @return the list of entity view objects + */ + List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, + UUID customerId, + TextPageLink pageLink); + + /** + * Find entity views by tenantId, customerId, type and page link. + * + * @param tenantId the tenantId + * @param customerId the customerId + * @param type the type + * @param pageLink the page link + * @return the list of entity view objects + */ + List findEntityViewsByTenantIdAndCustomerIdAndType(UUID tenantId, + UUID customerId, + String type, + TextPageLink pageLink); + + ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId); + + /** + * Find tenants entity view types. + * + * @return the list of tenant entity view type objects + */ + ListenableFuture> findTenantEntityViewTypesAsync(UUID tenantId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java new file mode 100644 index 0000000000..9c828663c1 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewService.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.entityview; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.TextPageData; +import org.thingsboard.server.common.data.page.TextPageLink; + +import java.util.List; + +/** + * Created by Victor Basanets on 8/27/2017. + */ +public interface EntityViewService { + + EntityView saveEntityView(EntityView entityView); + + EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId); + + EntityView unassignEntityViewFromCustomer(EntityViewId entityViewId); + + void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId); + + EntityView findEntityViewById(EntityViewId entityViewId); + + EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name); + + TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink); + + TextPageData findEntityViewByTenantIdAndType(TenantId tenantId, TextPageLink pageLink, String type); + + TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, TextPageLink pageLink); + + TextPageData findEntityViewsByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, TextPageLink pageLink, String type); + + ListenableFuture> findEntityViewsByQuery(EntityViewSearchQuery query); + + ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId); + + ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId); + + void deleteEntityView(EntityViewId entityViewId); + + void deleteEntityViewsByTenantId(TenantId tenantId); + + ListenableFuture> findEntityViewTypesByTenantId(TenantId tenantId); +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java new file mode 100644 index 0000000000..9f8949dc4e --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/entityview/EntityViewServiceImpl.java @@ -0,0 +1,368 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.entityview; + +import com.google.common.base.Function; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.cache.annotation.Caching; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.entityview.EntityViewSearchQuery; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.TextPageData; +import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.relation.EntitySearchDirection; +import org.thingsboard.server.dao.customer.CustomerDao; +import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.dao.service.PaginatedRemover; +import org.thingsboard.server.dao.tenant.TenantDao; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.data.CacheConstants.ENTITY_VIEW_CACHE; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; +import static org.thingsboard.server.dao.service.Validator.validateId; +import static org.thingsboard.server.dao.service.Validator.validatePageLink; +import static org.thingsboard.server.dao.service.Validator.validateString; + +/** + * Created by Victor Basanets on 8/28/2017. + */ +@Service +@Slf4j +public class EntityViewServiceImpl extends AbstractEntityService implements EntityViewService { + + public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; + public static final String INCORRECT_PAGE_LINK = "Incorrect page link "; + public static final String INCORRECT_CUSTOMER_ID = "Incorrect customerId "; + public static final String INCORRECT_ENTITY_VIEW_ID = "Incorrect entityViewId "; + + @Autowired + private EntityViewDao entityViewDao; + + @Autowired + private TenantDao tenantDao; + + @Autowired + private CustomerDao customerDao; + + @Autowired + private CacheManager cacheManager; + + @Caching(evict = { + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.entityId}"), + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.tenantId, #entityView.name}"), + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityView.id}")}) + @Override + public EntityView saveEntityView(EntityView entityView) { + log.trace("Executing save entity view [{}]", entityView); + entityViewValidator.validate(entityView); + EntityView savedEntityView = entityViewDao.save(entityView); + return savedEntityView; + } + + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") + @Override + public EntityView assignEntityViewToCustomer(EntityViewId entityViewId, CustomerId customerId) { + EntityView entityView = findEntityViewById(entityViewId); + entityView.setCustomerId(customerId); + return saveEntityView(entityView); + } + + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") + @Override + public EntityView unassignEntityViewFromCustomer(EntityViewId entityViewId) { + EntityView entityView = findEntityViewById(entityViewId); + entityView.setCustomerId(null); + return saveEntityView(entityView); + } + + @Override + public void unassignCustomerEntityViews(TenantId tenantId, CustomerId customerId) { + log.trace("Executing unassignCustomerEntityViews, tenantId [{}], customerId [{}]", tenantId, customerId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); + new CustomerEntityViewsUnAssigner(tenantId).removeEntities(customerId); + } + + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") + @Override + public EntityView findEntityViewById(EntityViewId entityViewId) { + log.trace("Executing findEntityViewById [{}]", entityViewId); + validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); + return entityViewDao.findById(entityViewId.getId()); + } + + @Cacheable(cacheNames = ENTITY_VIEW_CACHE, key = "{#tenantId, #name}") + @Override + public EntityView findEntityViewByTenantIdAndName(TenantId tenantId, String name) { + log.trace("Executing findEntityViewByTenantIdAndName [{}][{}]", tenantId, name); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + Optional entityViewOpt = entityViewDao.findEntityViewByTenantIdAndName(tenantId.getId(), name); + return entityViewOpt.orElse(null); + } + + @Override + public TextPageData findEntityViewByTenantId(TenantId tenantId, TextPageLink pageLink) { + log.trace("Executing findEntityViewsByTenantId, tenantId [{}], pageLink [{}]", tenantId, pageLink); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); + List entityViews = entityViewDao.findEntityViewsByTenantId(tenantId.getId(), pageLink); + return new TextPageData<>(entityViews, pageLink); + } + + @Override + public TextPageData findEntityViewByTenantIdAndType(TenantId tenantId, TextPageLink pageLink, String type) { + log.trace("Executing findEntityViewByTenantIdAndType, tenantId [{}], pageLink [{}], type [{}]", tenantId, pageLink, type); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); + validateString(type, "Incorrect type " + type); + List entityViews = entityViewDao.findEntityViewsByTenantIdAndType(tenantId.getId(), type, pageLink); + return new TextPageData<>(entityViews, pageLink); + } + + @Override + public TextPageData findEntityViewsByTenantIdAndCustomerId(TenantId tenantId, CustomerId customerId, + TextPageLink pageLink) { + log.trace("Executing findEntityViewByTenantIdAndCustomerId, tenantId [{}], customerId [{}]," + + " pageLink [{}]", tenantId, customerId, pageLink); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); + validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); + List entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerId(tenantId.getId(), + customerId.getId(), pageLink); + return new TextPageData<>(entityViews, pageLink); + } + + @Override + public TextPageData findEntityViewsByTenantIdAndCustomerIdAndType(TenantId tenantId, CustomerId customerId, TextPageLink pageLink, String type) { + log.trace("Executing findEntityViewsByTenantIdAndCustomerIdAndType, tenantId [{}], customerId [{}]," + + " pageLink [{}], type [{}]", tenantId, customerId, pageLink, type); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateId(customerId, INCORRECT_CUSTOMER_ID + customerId); + validatePageLink(pageLink, INCORRECT_PAGE_LINK + pageLink); + validateString(type, "Incorrect type " + type); + List entityViews = entityViewDao.findEntityViewsByTenantIdAndCustomerIdAndType(tenantId.getId(), + customerId.getId(), type, pageLink); + return new TextPageData<>(entityViews, pageLink); + } + + @Override + public ListenableFuture> findEntityViewsByQuery(EntityViewSearchQuery query) { + ListenableFuture> relations = relationService.findByQuery(query.toEntitySearchQuery()); + ListenableFuture> entityViews = Futures.transformAsync(relations, r -> { + EntitySearchDirection direction = query.toEntitySearchQuery().getParameters().getDirection(); + List> futures = new ArrayList<>(); + for (EntityRelation relation : r) { + EntityId entityId = direction == EntitySearchDirection.FROM ? relation.getTo() : relation.getFrom(); + if (entityId.getEntityType() == EntityType.ENTITY_VIEW) { + futures.add(findEntityViewByIdAsync(new EntityViewId(entityId.getId()))); + } + } + return Futures.successfulAsList(futures); + }); + + entityViews = Futures.transform(entityViews, new Function, List>() { + @Nullable + @Override + public List apply(@Nullable List entityViewList) { + return entityViewList == null ? Collections.emptyList() : entityViewList.stream().filter(entityView -> query.getEntityViewTypes().contains(entityView.getType())).collect(Collectors.toList()); + } + }); + + return entityViews; + } + + @Override + public ListenableFuture findEntityViewByIdAsync(EntityViewId entityViewId) { + log.trace("Executing findEntityViewById [{}]", entityViewId); + validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); + return entityViewDao.findByIdAsync(entityViewId.getId()); + } + + @Override + public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(TenantId tenantId, EntityId entityId) { + log.trace("Executing findEntityViewsByTenantIdAndEntityIdAsync, tenantId [{}], entityId [{}]", tenantId, entityId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + validateId(entityId.getId(), "Incorrect entityId" + entityId); + + List tenantIdAndEntityId = new ArrayList<>(); + tenantIdAndEntityId.add(tenantId); + tenantIdAndEntityId.add(entityId); + + Cache cache = cacheManager.getCache(ENTITY_VIEW_CACHE); + List fromCache = cache.get(tenantIdAndEntityId, List.class); + if (fromCache != null) { + return Futures.immediateFuture(fromCache); + } else { + ListenableFuture> entityViewsFuture = entityViewDao.findEntityViewsByTenantIdAndEntityIdAsync(tenantId.getId(), entityId.getId()); + Futures.addCallback(entityViewsFuture, + new FutureCallback>() { + @Override + public void onSuccess(@Nullable List result) { + cache.putIfAbsent(tenantIdAndEntityId, result); + } + + @Override + public void onFailure(Throwable t) { + log.error("Error while finding entity views by tenantId and entityId", t); + } + }); + return entityViewsFuture; + } + } + + @CacheEvict(cacheNames = ENTITY_VIEW_CACHE, key = "{#entityViewId}") + @Override + public void deleteEntityView(EntityViewId entityViewId) { + log.trace("Executing deleteEntityView [{}]", entityViewId); + validateId(entityViewId, INCORRECT_ENTITY_VIEW_ID + entityViewId); + deleteEntityRelations(entityViewId); + EntityView entityView = entityViewDao.findById(entityViewId.getId()); + cacheManager.getCache(ENTITY_VIEW_CACHE).evict(Arrays.asList(entityView.getTenantId(), entityView.getEntityId())); + cacheManager.getCache(ENTITY_VIEW_CACHE).evict(Arrays.asList(entityView.getTenantId(), entityView.getName())); + entityViewDao.removeById(entityViewId.getId()); + } + + @Override + public void deleteEntityViewsByTenantId(TenantId tenantId) { + log.trace("Executing deleteEntityViewsByTenantId, tenantId [{}]", tenantId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + tenantEntityViewRemover.removeEntities(tenantId); + } + + @Override + public ListenableFuture> findEntityViewTypesByTenantId(TenantId tenantId) { + log.trace("Executing findEntityViewTypesByTenantId, tenantId [{}]", tenantId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + ListenableFuture> tenantEntityViewTypes = entityViewDao.findTenantEntityViewTypesAsync(tenantId.getId()); + return Futures.transform(tenantEntityViewTypes, + entityViewTypes -> { + entityViewTypes.sort(Comparator.comparing(EntitySubtype::getType)); + return entityViewTypes; + }); + } + + private DataValidator entityViewValidator = + new DataValidator() { + + @Override + protected void validateCreate(EntityView entityView) { + entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()) + .ifPresent(e -> { + throw new DataValidationException("Entity view with such name already exists!"); + }); + } + + @Override + protected void validateUpdate(EntityView entityView) { + entityViewDao.findEntityViewByTenantIdAndName(entityView.getTenantId().getId(), entityView.getName()) + .ifPresent(e -> { + if (!e.getUuidId().equals(entityView.getUuidId())) { + throw new DataValidationException("Entity view with such name already exists!"); + } + }); + } + + @Override + protected void validateDataImpl(EntityView entityView) { + if (StringUtils.isEmpty(entityView.getType())) { + throw new DataValidationException("Entity View type should be specified!"); + } + if (StringUtils.isEmpty(entityView.getName())) { + throw new DataValidationException("Entity view name should be specified!"); + } + if (entityView.getTenantId() == null) { + throw new DataValidationException("Entity view should be assigned to tenant!"); + } else { + Tenant tenant = tenantDao.findById(entityView.getTenantId().getId()); + if (tenant == null) { + throw new DataValidationException("Entity view is referencing to non-existent tenant!"); + } + } + if (entityView.getCustomerId() == null) { + entityView.setCustomerId(new CustomerId(NULL_UUID)); + } else if (!entityView.getCustomerId().getId().equals(NULL_UUID)) { + Customer customer = customerDao.findById(entityView.getCustomerId().getId()); + if (customer == null) { + throw new DataValidationException("Can't assign entity view to non-existent customer!"); + } + if (!customer.getTenantId().getId().equals(entityView.getTenantId().getId())) { + throw new DataValidationException("Can't assign entity view to customer from different tenant!"); + } + } + } + }; + + private PaginatedRemover tenantEntityViewRemover = + new PaginatedRemover() { + + @Override + protected List findEntities(TenantId id, TextPageLink pageLink) { + return entityViewDao.findEntityViewsByTenantId(id.getId(), pageLink); + } + + @Override + protected void removeEntity(EntityView entity) { + deleteEntityView(new EntityViewId(entity.getUuidId())); + } + }; + + private class CustomerEntityViewsUnAssigner extends PaginatedRemover { + + private TenantId tenantId; + + CustomerEntityViewsUnAssigner(TenantId tenantId) { + this.tenantId = tenantId; + } + + @Override + protected List findEntities(CustomerId id, TextPageLink pageLink) { + return entityViewDao.findEntityViewsByTenantIdAndCustomerId(tenantId.getId(), id.getId(), pageLink); + } + + @Override + protected void removeEntity(EntityView entity) { + unassignEntityViewFromCustomer(new EntityViewId(entity.getUuidId())); + } + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 3a934eb5d2..86ba594aa5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -52,7 +52,6 @@ public class ModelConstants { public static final String ATTRIBUTE_KEY_COLUMN = "attribute_key"; public static final String LAST_UPDATE_TS_COLUMN = "last_update_ts"; - /** * Cassandra user constants. */ @@ -130,12 +129,12 @@ public class ModelConstants { * Cassandra device constants. */ public static final String DEVICE_COLUMN_FAMILY_NAME = "device"; + public static final String DEVICE_FAMILY_NAME = "device"; public static final String DEVICE_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; public static final String DEVICE_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; public static final String DEVICE_NAME_PROPERTY = "name"; public static final String DEVICE_TYPE_PROPERTY = "type"; public static final String DEVICE_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; - public static final String DEVICE_BY_TENANT_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "device_by_tenant_and_search_text"; public static final String DEVICE_BY_TENANT_BY_TYPE_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "device_by_tenant_by_type_and_search_text"; public static final String DEVICE_BY_CUSTOMER_AND_SEARCH_TEXT_COLUMN_FAMILY_NAME = "device_by_customer_and_search_text"; @@ -143,6 +142,26 @@ public class ModelConstants { public static final String DEVICE_BY_TENANT_AND_NAME_VIEW_NAME = "device_by_tenant_and_name"; public static final String DEVICE_TYPES_BY_TENANT_VIEW_NAME = "device_types_by_tenant"; + /** + * Cassandra entityView constants. + */ + public static final String ENTITY_VIEW_TABLE_FAMILY_NAME = "entity_view"; + public static final String ENTITY_VIEW_ENTITY_ID_PROPERTY = ENTITY_ID_COLUMN; + public static final String ENTITY_VIEW_TENANT_ID_PROPERTY = TENANT_ID_PROPERTY; + public static final String ENTITY_VIEW_CUSTOMER_ID_PROPERTY = CUSTOMER_ID_PROPERTY; + public static final String ENTITY_VIEW_NAME_PROPERTY = DEVICE_NAME_PROPERTY; + public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_CF = "entity_view_by_tenant_and_customer"; + public static final String ENTITY_VIEW_BY_TENANT_AND_CUSTOMER_AND_TYPE_CF = "entity_view_by_tenant_and_customer_and_type"; + public static final String ENTITY_VIEW_BY_TENANT_AND_ENTITY_ID_CF = "entity_view_by_tenant_and_entity_id"; + public static final String ENTITY_VIEW_KEYS_PROPERTY = "keys"; + public static final String ENTITY_VIEW_TYPE_PROPERTY = "type"; + public static final String ENTITY_VIEW_START_TS_PROPERTY = "start_ts"; + public static final String ENTITY_VIEW_END_TS_PROPERTY = "end_ts"; + public static final String ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY = ADDITIONAL_INFO_PROPERTY; + public static final String ENTITY_VIEW_BY_TENANT_AND_SEARCH_TEXT_CF = "entity_view_by_tenant_and_search_text"; + public static final String ENTITY_VIEW_BY_TENANT_BY_TYPE_AND_SEARCH_TEXT_CF = "entity_view_by_tenant_by_type_and_search_text"; + public static final String ENTITY_VIEW_BY_TENANT_AND_NAME = "entity_view_by_tenant_and_name"; + /** * Cassandra audit log constants. */ diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java new file mode 100644 index 0000000000..cda4217694 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/nosql/EntityViewEntity.java @@ -0,0 +1,164 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.model.nosql; + +import com.datastax.driver.core.utils.UUIDs; +import com.datastax.driver.mapping.annotations.Column; +import com.datastax.driver.mapping.annotations.PartitionKey; +import com.datastax.driver.mapping.annotations.Table; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import lombok.extern.slf4j.Slf4j; +import org.hibernate.annotations.Type; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.model.SearchTextEntity; + +import javax.persistence.EnumType; +import javax.persistence.Enumerated; +import java.io.IOException; +import java.util.UUID; + +import static org.thingsboard.server.dao.model.ModelConstants.DEVICE_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_TYPE_PROPERTY; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_VIEW_TABLE_FAMILY_NAME; +import static org.thingsboard.server.dao.model.ModelConstants.ID_PROPERTY; + +/** + * Created by Victor Basanets on 8/31/2017. + */ +@Data +@Table(name = ENTITY_VIEW_TABLE_FAMILY_NAME) +@EqualsAndHashCode +@ToString +@Slf4j +public class EntityViewEntity implements SearchTextEntity { + + @PartitionKey(value = 0) + @Column(name = ID_PROPERTY) + private UUID id; + + @Enumerated(EnumType.STRING) + @Column(name = ENTITY_TYPE_PROPERTY) + private EntityType entityType; + + @PartitionKey(value = 1) + @Column(name = ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY) + private UUID tenantId; + + @PartitionKey(value = 2) + @Column(name = ModelConstants.ENTITY_VIEW_CUSTOMER_ID_PROPERTY) + private UUID customerId; + + @PartitionKey(value = 3) + @Column(name = DEVICE_TYPE_PROPERTY) + private String type; + + @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) + private UUID entityId; + + @Column(name = ModelConstants.ENTITY_VIEW_NAME_PROPERTY) + private String name; + + @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY) + private String keys; + + @Column(name = ModelConstants.ENTITY_VIEW_START_TS_PROPERTY) + private long startTs; + + @Column(name = ModelConstants.ENTITY_VIEW_END_TS_PROPERTY) + private long endTs; + + @Column(name = ModelConstants.SEARCH_TEXT_PROPERTY) + private String searchText; + + @Type(type = "json") + @Column(name = ModelConstants.ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY) + private JsonNode additionalInfo; + + private static final ObjectMapper mapper = new ObjectMapper(); + + public EntityViewEntity() { + super(); + } + + public EntityViewEntity(EntityView entityView) { + if (entityView.getId() != null) { + this.id = entityView.getId().getId(); + } + if (entityView.getEntityId() != null) { + this.entityId = entityView.getEntityId().getId(); + this.entityType = entityView.getEntityId().getEntityType(); + } + if (entityView.getTenantId() != null) { + this.tenantId = entityView.getTenantId().getId(); + } + if (entityView.getCustomerId() != null) { + this.customerId = entityView.getCustomerId().getId(); + } + this.type = entityView.getType(); + this.name = entityView.getName(); + try { + this.keys = mapper.writeValueAsString(entityView.getKeys()); + } catch (IOException e) { + log.error("Unable to serialize entity view keys!", e); + } + this.startTs = entityView.getStartTimeMs(); + this.endTs = entityView.getEndTimeMs(); + this.searchText = entityView.getSearchText(); + this.additionalInfo = entityView.getAdditionalInfo(); + } + + @Override + public String getSearchTextSource() { + return name; + } + + @Override + public EntityView toData() { + EntityView entityView = new EntityView(new EntityViewId(id)); + entityView.setCreatedTime(UUIDs.unixTimestamp(id)); + if (entityId != null) { + entityView.setEntityId(EntityIdFactory.getByTypeAndId(entityType.name(), entityId.toString())); + } + if (tenantId != null) { + entityView.setTenantId(new TenantId(tenantId)); + } + if (customerId != null) { + entityView.setCustomerId(new CustomerId(customerId)); + } + entityView.setType(type); + entityView.setName(name); + try { + entityView.setKeys(mapper.readValue(keys, TelemetryEntityView.class)); + } catch (IOException e) { + log.error("Unable to read entity view keys!", e); + } + entityView.setStartTimeMs(startTs); + entityView.setEndTimeMs(endTs); + entityView.setAdditionalInfo(additionalInfo); + return entityView; + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvCompositeKey.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvCompositeKey.java index 3001e1279f..1c9f17a46d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvCompositeKey.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvCompositeKey.java @@ -20,14 +20,29 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.EntityType; +import javax.persistence.Column; +import javax.persistence.Embeddable; +import javax.persistence.EnumType; +import javax.persistence.Enumerated; import java.io.Serializable; +import static org.thingsboard.server.dao.model.ModelConstants.ATTRIBUTE_KEY_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.ATTRIBUTE_TYPE_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_ID_COLUMN; +import static org.thingsboard.server.dao.model.ModelConstants.ENTITY_TYPE_COLUMN; + @Data @AllArgsConstructor @NoArgsConstructor +@Embeddable public class AttributeKvCompositeKey implements Serializable { + @Enumerated(EnumType.STRING) + @Column(name = ENTITY_TYPE_COLUMN) private EntityType entityType; + @Column(name = ENTITY_ID_COLUMN) private String entityId; + @Column(name = ATTRIBUTE_TYPE_COLUMN) private String attributeType; + @Column(name = ATTRIBUTE_KEY_COLUMN) private String attributeKey; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java index 587a31485e..515c86c059 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AttributeKvEntity.java @@ -27,6 +27,7 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.dao.model.ToData; import javax.persistence.Column; +import javax.persistence.EmbeddedId; import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; @@ -48,25 +49,10 @@ import static org.thingsboard.server.dao.model.ModelConstants.STRING_VALUE_COLUM @Data @Entity @Table(name = "attribute_kv") -@IdClass(AttributeKvCompositeKey.class) public class AttributeKvEntity implements ToData, Serializable { - @Id - @Enumerated(EnumType.STRING) - @Column(name = ENTITY_TYPE_COLUMN) - private EntityType entityType; - - @Id - @Column(name = ENTITY_ID_COLUMN) - private String entityId; - - @Id - @Column(name = ATTRIBUTE_TYPE_COLUMN) - private String attributeType; - - @Id - @Column(name = ATTRIBUTE_KEY_COLUMN) - private String attributeKey; + @EmbeddedId + private AttributeKvCompositeKey id; @Column(name = BOOLEAN_VALUE_COLUMN) private Boolean booleanValue; @@ -87,13 +73,13 @@ public class AttributeKvEntity implements ToData, Serializable public AttributeKvEntry toData() { KvEntry kvEntry = null; if (strValue != null) { - kvEntry = new StringDataEntry(attributeKey, strValue); + kvEntry = new StringDataEntry(id.getAttributeKey(), strValue); } else if (booleanValue != null) { - kvEntry = new BooleanDataEntry(attributeKey, booleanValue); + kvEntry = new BooleanDataEntry(id.getAttributeKey(), booleanValue); } else if (doubleValue != null) { - kvEntry = new DoubleDataEntry(attributeKey, doubleValue); + kvEntry = new DoubleDataEntry(id.getAttributeKey(), doubleValue); } else if (longValue != null) { - kvEntry = new LongDataEntry(attributeKey, longValue); + kvEntry = new LongDataEntry(id.getAttributeKey(), longValue); } return new BaseAttributeKvEntry(kvEntry, lastUpdateTs); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ComponentDescriptorEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ComponentDescriptorEntity.java index 66fbcc3382..e4c6bb5318 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/ComponentDescriptorEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/ComponentDescriptorEntity.java @@ -34,6 +34,7 @@ import javax.persistence.Entity; import javax.persistence.EnumType; import javax.persistence.Enumerated; import javax.persistence.Table; +import javax.persistence.UniqueConstraint; @Data @EqualsAndHashCode(callSuper = true) @@ -53,7 +54,7 @@ public class ComponentDescriptorEntity extends BaseSqlEntity implements SearchTextEntity { + + @Column(name = ModelConstants.ENTITY_VIEW_ENTITY_ID_PROPERTY) + private String entityId; + + @Enumerated(EnumType.STRING) + @Column(name = ENTITY_TYPE_PROPERTY) + private EntityType entityType; + + @Column(name = ModelConstants.ENTITY_VIEW_TENANT_ID_PROPERTY) + private String tenantId; + + @Column(name = ModelConstants.ENTITY_VIEW_CUSTOMER_ID_PROPERTY) + private String customerId; + + @Column(name = ModelConstants.DEVICE_TYPE_PROPERTY) + private String type; + + @Column(name = ModelConstants.ENTITY_VIEW_NAME_PROPERTY) + private String name; + + @Column(name = ModelConstants.ENTITY_VIEW_KEYS_PROPERTY) + private String keys; + + @Column(name = ModelConstants.ENTITY_VIEW_START_TS_PROPERTY) + private long startTs; + + @Column(name = ModelConstants.ENTITY_VIEW_END_TS_PROPERTY) + private long endTs; + + @Column(name = ModelConstants.SEARCH_TEXT_PROPERTY) + private String searchText; + + @Type(type = "json") + @Column(name = ModelConstants.ENTITY_VIEW_ADDITIONAL_INFO_PROPERTY) + private JsonNode additionalInfo; + + private static final ObjectMapper mapper = new ObjectMapper(); + + public EntityViewEntity() { + super(); + } + + public EntityViewEntity(EntityView entityView) { + if (entityView.getId() != null) { + this.setId(entityView.getId().getId()); + } + if (entityView.getEntityId() != null) { + this.entityId = toString(entityView.getEntityId().getId()); + this.entityType = entityView.getEntityId().getEntityType(); + } + if (entityView.getTenantId() != null) { + this.tenantId = toString(entityView.getTenantId().getId()); + } + if (entityView.getCustomerId() != null) { + this.customerId = toString(entityView.getCustomerId().getId()); + } + this.type = entityView.getType(); + this.name = entityView.getName(); + try { + this.keys = mapper.writeValueAsString(entityView.getKeys()); + } catch (IOException e) { + log.error("Unable to serialize entity view keys!", e); + } + this.startTs = entityView.getStartTimeMs(); + this.endTs = entityView.getEndTimeMs(); + this.searchText = entityView.getSearchText(); + this.additionalInfo = entityView.getAdditionalInfo(); + } + + @Override + public String getSearchTextSource() { + return name; + } + + @Override + public void setSearchText(String searchText) { + this.searchText = searchText; + } + + @Override + public EntityView toData() { + EntityView entityView = new EntityView(new EntityViewId(getId())); + entityView.setCreatedTime(UUIDs.unixTimestamp(getId())); + + if (entityId != null) { + entityView.setEntityId(EntityIdFactory.getByTypeAndId(entityType.name(), toUUID(entityId).toString())); + } + if (tenantId != null) { + entityView.setTenantId(new TenantId(toUUID(tenantId))); + } + if (customerId != null) { + entityView.setCustomerId(new CustomerId(toUUID(customerId))); + } + entityView.setType(type); + entityView.setName(name); + try { + entityView.setKeys(mapper.readValue(keys, TelemetryEntityView.class)); + } catch (IOException e) { + log.error("Unable to read entity view keys!", e); + } + entityView.setStartTimeMs(startTs); + entityView.setEndTimeMs(endTs); + entityView.setAdditionalInfo(additionalInfo); + return entityView; + } +} 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 d1af1677db..b38110b460 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 @@ -35,7 +35,6 @@ import org.thingsboard.server.dao.model.type.ComponentTypeCodec; import org.thingsboard.server.dao.model.type.DeviceCredentialsTypeCodec; import org.thingsboard.server.dao.model.type.EntityTypeCodec; import org.thingsboard.server.dao.model.type.JsonCodec; -import org.thingsboard.server.dao.util.BufferedRateLimiter; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -49,7 +48,7 @@ public abstract class CassandraAbstractDao { private ConcurrentMap preparedStatementMap = new ConcurrentHashMap<>(); @Autowired - private BufferedRateLimiter rateLimiter; + private CassandraBufferedRateExecutor rateLimiter; private Session session; @@ -115,12 +114,12 @@ public abstract class CassandraAbstractDao { if (statement.getConsistencyLevel() == null) { statement.setConsistencyLevel(level); } - return new RateLimitedResultSetFuture(getSession(), rateLimiter, statement); + return rateLimiter.submit(new CassandraStatementTask(getSession(), statement)); } private static String statementToString(Statement statement) { if (statement instanceof BoundStatement) { - return ((BoundStatement)statement).preparedStatement().getQueryString(); + return ((BoundStatement) statement).preparedStatement().getQueryString(); } else { return statement.toString(); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java new file mode 100644 index 0000000000..a3490bffdd --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraBufferedRateExecutor.java @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql; + +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.ResultSetFuture; +import com.google.common.util.concurrent.SettableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.thingsboard.server.dao.util.AbstractBufferedRateExecutor; +import org.thingsboard.server.dao.util.AsyncTaskContext; +import org.thingsboard.server.dao.util.NoSqlAnyDao; + +import javax.annotation.PreDestroy; + +/** + * Created by ashvayka on 24.10.18. + */ +@Component +@Slf4j +@NoSqlAnyDao +public class CassandraBufferedRateExecutor extends AbstractBufferedRateExecutor { + + public CassandraBufferedRateExecutor( + @Value("${cassandra.query.buffer_size}") int queueLimit, + @Value("${cassandra.query.concurrent_limit}") int concurrencyLimit, + @Value("${cassandra.query.permit_max_wait_time}") long maxWaitTime, + @Value("${cassandra.query.dispatcher_threads:2}") int dispatcherThreads, + @Value("${cassandra.query.callback_threads:2}") int callbackThreads, + @Value("${cassandra.query.poll_ms:50}") long pollMs) { + super(queueLimit, concurrencyLimit, maxWaitTime, dispatcherThreads, callbackThreads, pollMs); + } + + @Scheduled(fixedDelayString = "${cassandra.query.rate_limit_print_interval_ms}") + public void printStats() { + log.info("Permits queueSize [{}] totalAdded [{}] totalLaunched [{}] totalReleased [{}] totalFailed [{}] totalExpired [{}] totalRejected [{}] currBuffer [{}] ", + getQueueSize(), + totalAdded.getAndSet(0), totalLaunched.getAndSet(0), totalReleased.getAndSet(0), + totalFailed.getAndSet(0), totalExpired.getAndSet(0), totalRejected.getAndSet(0), + concurrencyLevel.get()); + } + + @PreDestroy + public void stop() { + super.stop(); + } + + @Override + protected SettableFuture create() { + return SettableFuture.create(); + } + + @Override + protected ResultSetFuture wrap(CassandraStatementTask task, SettableFuture future) { + return new TbResultSetFuture(future); + } + + @Override + protected ResultSetFuture execute(AsyncTaskContext taskCtx) { + CassandraStatementTask task = taskCtx.getTask(); + return task.getSession().executeAsync(task.getStatement()); + } + +} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java similarity index 62% rename from transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java rename to dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java index bf83a1fd98..19e56402db 100644 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/MqttTransportAdaptor.java +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraStatementTask.java @@ -13,14 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.transport.mqtt.adaptors; +package org.thingsboard.server.dao.nosql; -import io.netty.handler.codec.mqtt.MqttMessage; -import org.thingsboard.server.common.transport.TransportAdaptor; -import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; +import com.datastax.driver.core.Session; +import com.datastax.driver.core.Statement; +import lombok.Data; +import org.thingsboard.server.dao.util.AsyncTask; /** - * @author Andrew Shvayka + * Created by ashvayka on 24.10.18. */ -public interface MqttTransportAdaptor extends TransportAdaptor { +@Data +public class CassandraStatementTask implements AsyncTask { + + private final Session session; + private final Statement statement; + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/TbResultSetFuture.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/TbResultSetFuture.java new file mode 100644 index 0000000000..574a5f5e69 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/TbResultSetFuture.java @@ -0,0 +1,94 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql; + +import com.datastax.driver.core.ResultSet; +import com.datastax.driver.core.ResultSetFuture; +import com.google.common.util.concurrent.SettableFuture; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Created by ashvayka on 24.10.18. + */ +public class TbResultSetFuture implements ResultSetFuture { + + private final SettableFuture mainFuture; + + public TbResultSetFuture(SettableFuture mainFuture) { + this.mainFuture = mainFuture; + } + + @Override + public ResultSet getUninterruptibly() { + return getSafe(); + } + + @Override + public ResultSet getUninterruptibly(long timeout, TimeUnit unit) throws TimeoutException { + return getSafe(timeout, unit); + } + + @Override + public boolean cancel(boolean mayInterruptIfRunning) { + return mainFuture.cancel(mayInterruptIfRunning); + } + + @Override + public boolean isCancelled() { + return mainFuture.isCancelled(); + } + + @Override + public boolean isDone() { + return mainFuture.isDone(); + } + + @Override + public ResultSet get() throws InterruptedException, ExecutionException { + return mainFuture.get(); + } + + @Override + public ResultSet get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { + return mainFuture.get(timeout, unit); + } + + @Override + public void addListener(Runnable listener, Executor executor) { + mainFuture.addListener(listener, executor); + } + + private ResultSet getSafe() { + try { + return mainFuture.get(); + } catch (InterruptedException | ExecutionException e) { + throw new IllegalStateException(e); + } + } + + private ResultSet getSafe(long timeout, TimeUnit unit) throws TimeoutException { + try { + return mainFuture.get(timeout, unit); + } catch (InterruptedException | ExecutionException e) { + throw new IllegalStateException(e); + } + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/MsgQueue.java b/dao/src/main/java/org/thingsboard/server/dao/queue/MsgQueue.java deleted file mode 100644 index 19021eb95c..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/MsgQueue.java +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.queue; - -import com.google.common.util.concurrent.ListenableFuture; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; - -import java.util.UUID; - -public interface MsgQueue { - - ListenableFuture put(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition); - - ListenableFuture ack(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition); - - Iterable findUnprocessed(TenantId tenantId, UUID nodeId, long clusterPartition); - - ListenableFuture cleanUp(TenantId tenantId); - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/QueueBenchmark.java b/dao/src/main/java/org/thingsboard/server/dao/queue/QueueBenchmark.java deleted file mode 100644 index ca61a63a4c..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/QueueBenchmark.java +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.queue; - -import com.datastax.driver.core.Cluster; -import com.datastax.driver.core.HostDistance; -import com.datastax.driver.core.PoolingOptions; -import com.datastax.driver.core.Session; -import com.datastax.driver.core.utils.UUIDs; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.CommandLineRunner; -import org.springframework.boot.SpringApplication; -import org.springframework.context.annotation.Bean; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.RuleChainId; -import org.thingsboard.server.common.data.id.RuleNodeId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.common.msg.TbMsgDataType; -import org.thingsboard.server.common.msg.TbMsgMetaData; - -import javax.annotation.Nullable; -import java.net.InetSocketAddress; -import java.util.UUID; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -//@SpringBootApplication -//@EnableAutoConfiguration -//@ComponentScan({"org.thingsboard.rule.engine"}) -//@PropertySource("classpath:processing-pipeline.properties") -@Slf4j -public class QueueBenchmark implements CommandLineRunner { - - public static void main(String[] args) { - try { - SpringApplication.run(QueueBenchmark.class, args); - } catch (Throwable th) { - th.printStackTrace(); - System.exit(0); - } - } - - @Autowired - private MsgQueue msgQueue; - - @Override - public void run(String... strings) throws Exception { - System.out.println("It works + " + msgQueue); - - - long start = System.currentTimeMillis(); - int msgCount = 10000000; - AtomicLong count = new AtomicLong(0); - ExecutorService service = Executors.newFixedThreadPool(100); - - CountDownLatch latch = new CountDownLatch(msgCount); - for (int i = 0; i < msgCount; i++) { - service.submit(() -> { - boolean isFinished = false; - while (!isFinished) { - try { - TbMsg msg = randomMsg(); - UUID nodeId = UUIDs.timeBased(); - ListenableFuture put = msgQueue.put(new TenantId(EntityId.NULL_UUID), msg, nodeId, 100L); -// ListenableFuture put = msgQueue.ack(msg, nodeId, 100L); - Futures.addCallback(put, new FutureCallback() { - @Override - public void onSuccess(@Nullable Void result) { - latch.countDown(); - } - - @Override - public void onFailure(Throwable t) { -// t.printStackTrace(); - System.out.println("onFailure, because:" + t.getMessage()); - latch.countDown(); - } - }); - isFinished = true; - } catch (Throwable th) { -// th.printStackTrace(); - System.out.println("Repeat query, because:" + th.getMessage()); -// latch.countDown(); - } - } - }); - } - - long prev = 0L; - while (latch.getCount() != 0) { - TimeUnit.SECONDS.sleep(1); - long curr = latch.getCount(); - long rps = prev - curr; - prev = curr; - System.out.println("rps = " + rps); - } - - long end = System.currentTimeMillis(); - System.out.println("final rps = " + (msgCount / (end - start) * 1000)); - - System.out.println("Finished"); - - } - - private TbMsg randomMsg() { - TbMsgMetaData metaData = new TbMsgMetaData(); - metaData.putValue("key", "value"); - String dataStr = "someContent"; - return new TbMsg(UUIDs.timeBased(), "type", null, metaData, TbMsgDataType.JSON, dataStr, new RuleChainId(UUIDs.timeBased()), new RuleNodeId(UUIDs.timeBased()), 0L); - } - - @Bean - public Session session() { - Cluster thingsboard = Cluster.builder() - .addContactPointsWithPorts(new InetSocketAddress("127.0.0.1", 9042)) - .withClusterName("thingsboard") -// .withSocketOptions(socketOpts.getOpts()) - .withPoolingOptions(new PoolingOptions() - .setMaxRequestsPerConnection(HostDistance.LOCAL, 32768) - .setMaxRequestsPerConnection(HostDistance.REMOTE, 32768)).build(); - - Session session = thingsboard.connect("thingsboard"); - return session; - } - - @Bean - public int defaultTtl() { - return 6000; - } - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgQueue.java b/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgQueue.java deleted file mode 100644 index 93057784f6..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgQueue.java +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.queue.memory; - -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.dao.queue.MsgQueue; - -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; - -/** - * Created by ashvayka on 27.04.18. - */ -@Component -@ConditionalOnProperty(prefix = "actors.rule.queue", value = "type", havingValue = "memory", matchIfMissing = true) -@Slf4j -public class InMemoryMsgQueue implements MsgQueue { - - private ListeningExecutorService queueExecutor; - private Map>> data = new HashMap<>(); - - @PostConstruct - public void init() { - // Should be always single threaded due to absence of locks. - queueExecutor = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); - } - - @PreDestroy - public void stop() { - if (queueExecutor != null) { - queueExecutor.shutdownNow(); - } - } - - @Override - public ListenableFuture put(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition) { - return queueExecutor.submit(() -> { - data.computeIfAbsent(tenantId, key -> new HashMap<>()). - computeIfAbsent(new InMemoryMsgKey(nodeId, clusterPartition), key -> new HashMap<>()).put(msg.getId(), msg); - return null; - }); - } - - @Override - public ListenableFuture ack(TenantId tenantId, TbMsg msg, UUID nodeId, long clusterPartition) { - return queueExecutor.submit(() -> { - Map> tenantMap = data.get(tenantId); - if (tenantMap != null) { - InMemoryMsgKey key = new InMemoryMsgKey(nodeId, clusterPartition); - Map map = tenantMap.get(key); - if (map != null) { - map.remove(msg.getId()); - if (map.isEmpty()) { - tenantMap.remove(key); - } - } - if (tenantMap.isEmpty()) { - data.remove(tenantId); - } - } - return null; - }); - } - - @Override - public Iterable findUnprocessed(TenantId tenantId, UUID nodeId, long clusterPartition) { - ListenableFuture> list = queueExecutor.submit(() -> { - Map> tenantMap = data.get(tenantId); - if (tenantMap != null) { - InMemoryMsgKey key = new InMemoryMsgKey(nodeId, clusterPartition); - Map map = tenantMap.get(key); - if (map != null) { - return new ArrayList<>(map.values()); - } else { - return Collections.emptyList(); - } - } else { - return Collections.emptyList(); - } - }); - try { - return list.get(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); - } - } - - @Override - public ListenableFuture cleanUp(TenantId tenantId) { - return queueExecutor.submit(() -> { - data.remove(tenantId); - return null; - }); - } -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java index c76cefef19..d7168864a0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java @@ -15,7 +15,9 @@ */ package org.thingsboard.server.dao.sql.attributes; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.dao.model.sql.AttributeKvCompositeKey; import org.thingsboard.server.dao.model.sql.AttributeKvEntity; @@ -26,8 +28,11 @@ import java.util.List; @SqlDao public interface AttributeKvRepository extends CrudRepository { - List findAllByEntityTypeAndEntityIdAndAttributeType(EntityType entityType, - String entityId, - String attributeType); + @Query("SELECT a FROM AttributeKvEntity a WHERE a.id.entityType = :entityType " + + "AND a.id.entityId = :entityId " + + "AND a.id.attributeType = :attributeType") + List findAllByEntityTypeAndEntityIdAndAttributeType(@Param("entityType") EntityType entityType, + @Param("entityId") String entityId, + @Param("attributeType") String attributeType); } 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 0dabf4cfb5..4ac0c0ca38 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 @@ -79,10 +79,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl @Override public ListenableFuture save(EntityId entityId, String attributeType, AttributeKvEntry attribute) { AttributeKvEntity entity = new AttributeKvEntity(); - entity.setEntityType(entityId.getEntityType()); - entity.setEntityId(fromTimeUUID(entityId.getId())); - entity.setAttributeType(attributeType); - entity.setAttributeKey(attribute.getKey()); + entity.setId(new AttributeKvCompositeKey(entityId.getEntityType(), fromTimeUUID(entityId.getId()), attributeType, attribute.getKey())); entity.setLastUpdateTs(attribute.getLastUpdateTs()); entity.setStrValue(attribute.getStrValue().orElse(null)); entity.setDoubleValue(attribute.getDoubleValue().orElse(null)); @@ -100,10 +97,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl .stream() .map(key -> { AttributeKvEntity entityToDelete = new AttributeKvEntity(); - entityToDelete.setEntityType(entityId.getEntityType()); - entityToDelete.setEntityId(fromTimeUUID(entityId.getId())); - entityToDelete.setAttributeType(attributeType); - entityToDelete.setAttributeKey(key); + entityToDelete.setId(new AttributeKvCompositeKey(entityId.getEntityType(), fromTimeUUID(entityId.getId()), attributeType, key)); return entityToDelete; }).collect(Collectors.toList()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java new file mode 100644 index 0000000000..efd1bd9cd8 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/EntityViewRepository.java @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql.entityview; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.Param; +import org.thingsboard.server.dao.model.sql.EntityViewEntity; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.List; + +/** + * Created by Victor Basanets on 8/31/2017. + */ +@SqlDao +public interface EntityViewRepository extends CrudRepository { + + @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:textSearch, '%')) " + + "AND e.id > :idOffset ORDER BY e.id") + List findByTenantId(@Param("tenantId") String tenantId, + @Param("textSearch") String textSearch, + @Param("idOffset") String idOffset, + Pageable pageable); + + @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + + "AND e.type = :type " + + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:textSearch, '%')) " + + "AND e.id > :idOffset ORDER BY e.id") + List findByTenantIdAndType(@Param("tenantId") String tenantId, + @Param("type") String type, + @Param("textSearch") String textSearch, + @Param("idOffset") String idOffset, + Pageable pageable); + + @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + + "AND e.customerId = :customerId " + + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:searchText, '%')) " + + "AND e.id > :idOffset ORDER BY e.id") + List findByTenantIdAndCustomerId(@Param("tenantId") String tenantId, + @Param("customerId") String customerId, + @Param("searchText") String searchText, + @Param("idOffset") String idOffset, + Pageable pageable); + + @Query("SELECT e FROM EntityViewEntity e WHERE e.tenantId = :tenantId " + + "AND e.customerId = :customerId " + + "AND e.type = :type " + + "AND LOWER(e.searchText) LIKE LOWER(CONCAT(:searchText, '%')) " + + "AND e.id > :idOffset ORDER BY e.id") + List findByTenantIdAndCustomerIdAndType(@Param("tenantId") String tenantId, + @Param("customerId") String customerId, + @Param("type") String type, + @Param("searchText") String searchText, + @Param("idOffset") String idOffset, + Pageable pageable); + + EntityViewEntity findByTenantIdAndName(String tenantId, String name); + + List findAllByTenantIdAndEntityId(String tenantId, String entityId); + + @Query("SELECT DISTINCT ev.type FROM EntityViewEntity ev WHERE ev.tenantId = :tenantId") + List findTenantEntityViewTypes(@Param("tenantId") String tenantId); +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java new file mode 100644 index 0000000000..1ba9b9e3cf --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/entityview/JpaEntityViewDao.java @@ -0,0 +1,141 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql.entityview; + +import com.google.common.util.concurrent.ListenableFuture; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.UUIDConverter; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.TextPageLink; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.entityview.EntityViewDao; +import org.thingsboard.server.dao.model.sql.EntityViewEntity; +import org.thingsboard.server.dao.sql.JpaAbstractSearchTextDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID; +import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID_STR; + +/** + * Created by Victor Basanets on 8/31/2017. + */ +@Component +@SqlDao +public class JpaEntityViewDao extends JpaAbstractSearchTextDao + implements EntityViewDao { + + @Autowired + private EntityViewRepository entityViewRepository; + + @Override + protected Class getEntityClass() { + return EntityViewEntity.class; + } + + @Override + protected CrudRepository getCrudRepository() { + return entityViewRepository; + } + + @Override + public List findEntityViewsByTenantId(UUID tenantId, TextPageLink pageLink) { + return DaoUtil.convertDataList( + entityViewRepository.findByTenantId( + fromTimeUUID(tenantId), + Objects.toString(pageLink.getTextSearch(), ""), + pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), + new PageRequest(0, pageLink.getLimit()))); + } + + @Override + public List findEntityViewsByTenantIdAndType(UUID tenantId, String type, TextPageLink pageLink) { + return DaoUtil.convertDataList( + entityViewRepository.findByTenantIdAndType( + fromTimeUUID(tenantId), + type, + Objects.toString(pageLink.getTextSearch(), ""), + pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), + new PageRequest(0, pageLink.getLimit()))); + } + + @Override + public Optional findEntityViewByTenantIdAndName(UUID tenantId, String name) { + return Optional.ofNullable( + DaoUtil.getData(entityViewRepository.findByTenantIdAndName(fromTimeUUID(tenantId), name))); + } + + @Override + public List findEntityViewsByTenantIdAndCustomerId(UUID tenantId, + UUID customerId, + TextPageLink pageLink) { + return DaoUtil.convertDataList( + entityViewRepository.findByTenantIdAndCustomerId( + fromTimeUUID(tenantId), + fromTimeUUID(customerId), + Objects.toString(pageLink.getTextSearch(), ""), + pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), + new PageRequest(0, pageLink.getLimit()) + )); + } + + @Override + public List findEntityViewsByTenantIdAndCustomerIdAndType(UUID tenantId, UUID customerId, String type, TextPageLink pageLink) { + return DaoUtil.convertDataList( + entityViewRepository.findByTenantIdAndCustomerIdAndType( + fromTimeUUID(tenantId), + fromTimeUUID(customerId), + type, + Objects.toString(pageLink.getTextSearch(), ""), + pageLink.getIdOffset() == null ? NULL_UUID_STR : fromTimeUUID(pageLink.getIdOffset()), + new PageRequest(0, pageLink.getLimit()) + )); + } + + @Override + public ListenableFuture> findEntityViewsByTenantIdAndEntityIdAsync(UUID tenantId, UUID entityId) { + return service.submit(() -> DaoUtil.convertDataList( + entityViewRepository.findAllByTenantIdAndEntityId(UUIDConverter.fromTimeUUID(tenantId), UUIDConverter.fromTimeUUID(entityId)))); + } + + @Override + public ListenableFuture> findTenantEntityViewTypesAsync(UUID tenantId) { + return service.submit(() -> convertTenantEntityViewTypesToDto(tenantId, entityViewRepository.findTenantEntityViewTypes(fromTimeUUID(tenantId)))); + } + + private List convertTenantEntityViewTypesToDto(UUID tenantId, List types) { + List list = Collections.emptyList(); + if (types != null && !types.isEmpty()) { + list = new ArrayList<>(); + for (String type : types) { + list.add(new EntitySubtype(new TenantId(tenantId), EntityType.ENTITY_VIEW, type)); + } + } + return list; + } +} 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 1eb2f0026d..04227a3825 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/JpaTimeseriesDao.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.sql.timeseries; import com.google.common.base.Function; import com.google.common.collect.Lists; +import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; @@ -26,10 +27,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.UUIDConverter; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; @@ -40,9 +43,10 @@ import org.thingsboard.server.dao.model.sql.TsKvEntity; import org.thingsboard.server.dao.model.sql.TsKvLatestCompositeKey; import org.thingsboard.server.dao.model.sql.TsKvLatestEntity; import org.thingsboard.server.dao.sql.JpaAbstractDaoListeningExecutorService; +import org.thingsboard.server.dao.timeseries.SimpleListenableFuture; import org.thingsboard.server.dao.timeseries.TimeseriesDao; import org.thingsboard.server.dao.timeseries.TsInsertExecutorType; -import org.thingsboard.server.dao.util.SqlDao; +import org.thingsboard.server.dao.util.SqlTsDao; import javax.annotation.Nullable; import javax.annotation.PostConstruct; @@ -51,6 +55,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; import java.util.concurrent.Executors; import java.util.stream.Collectors; @@ -59,9 +64,11 @@ import static org.thingsboard.server.common.data.UUIDConverter.fromTimeUUID; @Component @Slf4j -@SqlDao +@SqlTsDao public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService implements TimeseriesDao { + private static final String DESC_ORDER = "DESC"; + @Value("${sql.ts_inserts_executor_type}") private String insertExecutorType; @@ -238,7 +245,9 @@ public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService imp query.getKey(), query.getStartTs(), query.getEndTs(), - new PageRequest(0, query.getLimit())))); + new PageRequest(0, query.getLimit(), + new Sort(Sort.Direction.fromString( + query.getOrderBy()), "ts"))))); } @Override @@ -322,14 +331,72 @@ public class JpaTimeseriesDao extends JpaAbstractDaoListeningExecutorService imp @Override public ListenableFuture removeLatest(EntityId entityId, DeleteTsKvQuery query) { - TsKvLatestEntity latestEntity = new TsKvLatestEntity(); - latestEntity.setEntityType(entityId.getEntityType()); - latestEntity.setEntityId(fromTimeUUID(entityId.getId())); - latestEntity.setKey(query.getKey()); - return service.submit(() -> { - tsKvLatestRepository.delete(latestEntity); - return null; + ListenableFuture latestFuture = findLatest(entityId, query.getKey()); + + ListenableFuture booleanFuture = Futures.transform(latestFuture, tsKvEntry -> { + long ts = tsKvEntry.getTs(); + return ts > query.getStartTs() && ts <= query.getEndTs(); + }, service); + + ListenableFuture removedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> { + if (isRemove) { + TsKvLatestEntity latestEntity = new TsKvLatestEntity(); + latestEntity.setEntityType(entityId.getEntityType()); + latestEntity.setEntityId(fromTimeUUID(entityId.getId())); + latestEntity.setKey(query.getKey()); + return service.submit(() -> { + tsKvLatestRepository.delete(latestEntity); + return null; + }); + } + return Futures.immediateFuture(null); + }, service); + + final SimpleListenableFuture resultFuture = new SimpleListenableFuture<>(); + Futures.addCallback(removedLatestFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + if (query.getRewriteLatestIfDeleted()) { + ListenableFuture savedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> { + if (isRemove) { + return getNewLatestEntryFuture(entityId, query); + } + return Futures.immediateFuture(null); + }, service); + + try { + resultFuture.set(savedLatestFuture.get()); + } catch (InterruptedException | ExecutionException e) { + log.warn("Could not get latest saved value for [{}], {}", entityId, query.getKey(), e); + } + } else { + resultFuture.set(null); + } + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to process remove of the latest value", entityId, t); + } }); + return resultFuture; + } + + private ListenableFuture getNewLatestEntryFuture(EntityId entityId, DeleteTsKvQuery query) { + long startTs = 0; + long endTs = query.getStartTs() - 1; + ReadTsKvQuery findNewLatestQuery = new BaseReadTsKvQuery(query.getKey(), startTs, endTs, endTs - startTs, 1, + Aggregation.NONE, DESC_ORDER); + ListenableFuture> future = findAllAsync(entityId, findNewLatestQuery); + + return Futures.transformAsync(future, entryList -> { + if (entryList.size() == 1) { + return saveLatest(entityId, entryList.get(0)); + } else { + log.trace("Could not find new latest value for [{}], key - {}", entityId, query.getKey()); + } + return Futures.immediateFuture(null); + }, service); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java index 2b39d2596e..296d173cf7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/timeseries/TsKvRepository.java @@ -35,7 +35,7 @@ public interface TsKvRepository extends CrudRepository :startTs AND tskv.ts < :endTs ORDER BY tskv.ts DESC") + "AND tskv.ts > :startTs AND tskv.ts < :endTs") List findAllWithLimit(@Param("entityId") String entityId, @Param("entityType") EntityType entityType, @Param("entityKey") String key, @@ -47,7 +47,7 @@ public interface TsKvRepository extends CrudRepository :startTs AND tskv.ts < :endTs") + "AND tskv.ts > :startTs AND tskv.ts <= :endTs") void delete(@Param("entityId") String entityId, @Param("entityType") EntityType entityType, @Param("entityKey") String key, diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index d92c9410e5..189c7139ac 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -29,6 +29,7 @@ import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.entity.AbstractEntityService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DataValidator; @@ -63,6 +64,9 @@ public class TenantServiceImpl extends AbstractEntityService implements TenantSe @Autowired private DeviceService deviceService; + @Autowired + private EntityViewService entityViewService; + @Autowired private WidgetsBundleService widgetsBundleService; @@ -101,6 +105,7 @@ public class TenantServiceImpl extends AbstractEntityService implements TenantSe customerService.deleteCustomersByTenantId(tenantId); widgetsBundleService.deleteWidgetsBundlesByTenantId(tenantId); dashboardService.deleteDashboardsByTenantId(tenantId); + entityViewService.deleteEntityViewsByTenantId(tenantId); assetService.deleteAssetsByTenantId(tenantId); deviceService.deleteDevicesByTenantId(tenantId); userService.deleteTenantAdmins(tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 98e859f39f..10064999e1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -20,16 +20,25 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityViewId; +import org.thingsboard.server.common.data.kv.Aggregation; +import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.DeleteTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.Validator; +import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.stream.Collectors; import static org.apache.commons.lang3.StringUtils.isBlank; @@ -40,16 +49,30 @@ import static org.apache.commons.lang3.StringUtils.isBlank; @Slf4j public class BaseTimeseriesService implements TimeseriesService { - public static final int INSERTS_PER_ENTRY = 3; - public static final int DELETES_PER_ENTRY = INSERTS_PER_ENTRY; + private static final int INSERTS_PER_ENTRY = 3; + private static final int DELETES_PER_ENTRY = INSERTS_PER_ENTRY; + + @Value("${database.ts_max_intervals}") + private long maxTsIntervals; @Autowired private TimeseriesDao timeseriesDao; + @Autowired + private EntityViewService entityViewService; + @Override public ListenableFuture> findAll(EntityId entityId, List queries) { validate(entityId); - queries.forEach(BaseTimeseriesService::validate); + queries.forEach(this::validate); + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); + List filteredQueries = + queries.stream() + .filter(query -> entityView.getKeys().getTimeseries().isEmpty() || entityView.getKeys().getTimeseries().contains(query.getKey())) + .collect(Collectors.toList()); + return timeseriesDao.findAllAsync(entityView.getEntityId(), updateQueriesForEntityView(entityView, filteredQueries)); + } return timeseriesDao.findAllAsync(entityId, queries); } @@ -58,6 +81,27 @@ public class BaseTimeseriesService implements TimeseriesService { validate(entityId); List> futures = Lists.newArrayListWithExpectedSize(keys.size()); keys.forEach(key -> Validator.validateString(key, "Incorrect key " + key)); + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); + List filteredKeys = new ArrayList<>(keys); + if (entityView.getKeys() != null && entityView.getKeys().getTimeseries() != null && + !entityView.getKeys().getTimeseries().isEmpty()) { + filteredKeys.retainAll(entityView.getKeys().getTimeseries()); + } + List queries = + filteredKeys.stream() + .map(key -> { + long endTs = entityView.getEndTimeMs() != 0 ? entityView.getEndTimeMs() : Long.MAX_VALUE; + return new BaseReadTsKvQuery(key, entityView.getStartTimeMs(), endTs, 1, "DESC"); + }) + .collect(Collectors.toList()); + + if (queries.size() > 0) { + return timeseriesDao.findAllAsync(entityView.getEntityId(), queries); + } else { + return Futures.immediateFuture(new ArrayList<>()); + } + } keys.forEach(key -> futures.add(timeseriesDao.findLatest(entityId, key))); return Futures.allAsList(futures); } @@ -65,7 +109,17 @@ public class BaseTimeseriesService implements TimeseriesService { @Override public ListenableFuture> findAllLatest(EntityId entityId) { validate(entityId); - return timeseriesDao.findAllLatest(entityId); + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + EntityView entityView = entityViewService.findEntityViewById((EntityViewId) entityId); + if (entityView.getKeys() != null && entityView.getKeys().getTimeseries() != null && + !entityView.getKeys().getTimeseries().isEmpty()) { + return findLatest(entityId, entityView.getKeys().getTimeseries()); + } else { + return Futures.immediateFuture(new ArrayList<>()); + } + } else { + return timeseriesDao.findAllLatest(entityId); + } } @Override @@ -92,11 +146,33 @@ public class BaseTimeseriesService implements TimeseriesService { } private void saveAndRegisterFutures(List> futures, EntityId entityId, TsKvEntry tsKvEntry, long ttl) { + if (entityId.getEntityType().equals(EntityType.ENTITY_VIEW)) { + throw new IncorrectParameterException("Telemetry data can't be stored for entity view. Only read only"); + } futures.add(timeseriesDao.savePartition(entityId, tsKvEntry.getTs(), tsKvEntry.getKey(), ttl)); futures.add(timeseriesDao.saveLatest(entityId, tsKvEntry)); futures.add(timeseriesDao.save(entityId, tsKvEntry, ttl)); } + private List updateQueriesForEntityView(EntityView entityView, List queries) { + return queries.stream().map(query -> { + long startTs; + if (entityView.getStartTimeMs() != 0 && entityView.getStartTimeMs() > query.getStartTs()) { + startTs = entityView.getStartTimeMs(); + } else { + startTs = query.getStartTs(); + } + + long endTs; + if (entityView.getEndTimeMs() != 0 && entityView.getEndTimeMs() < query.getEndTs()) { + endTs = entityView.getEndTimeMs(); + } else { + endTs = query.getEndTs(); + } + return new BaseReadTsKvQuery(query.getKey(), startTs, endTs, query.getInterval(), query.getLimit(), query.getAggregation()); + }).collect(Collectors.toList()); + } + @Override public ListenableFuture> remove(EntityId entityId, List deleteTsKvQueries) { validate(entityId); @@ -118,7 +194,7 @@ public class BaseTimeseriesService implements TimeseriesService { Validator.validateEntityId(entityId, "Incorrect entityId " + entityId); } - private static void validate(ReadTsKvQuery query) { + private void validate(ReadTsKvQuery query) { if (query == null) { throw new IncorrectParameterException("ReadTsKvQuery can't be null"); } else if (isBlank(query.getKey())) { @@ -126,6 +202,14 @@ public class BaseTimeseriesService implements TimeseriesService { } else if (query.getAggregation() == null) { throw new IncorrectParameterException("Incorrect ReadTsKvQuery. Aggregation can't be empty"); } + if(!Aggregation.NONE.equals(query.getAggregation())) { + long step = Math.max(query.getInterval(), 1000); + long intervalCounts = (query.getEndTs() - query.getStartTs()) / step; + if (intervalCounts > maxTsIntervals || intervalCounts < 0) { + throw new IncorrectParameterException("Incorrect TsKvQuery. Number of intervals is to high - " + intervalCounts + ". " + + "Please increase 'interval' parameter for your query or reduce the time range of the query."); + } + } } private static void validate(DeleteTsKvQuery query) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java index 03d569d78e..fdc69f9262 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java @@ -48,7 +48,7 @@ import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.nosql.CassandraAbstractAsyncDao; -import org.thingsboard.server.dao.util.NoSqlDao; +import org.thingsboard.server.dao.util.NoSqlTsDao; import javax.annotation.Nullable; import javax.annotation.PostConstruct; @@ -61,6 +61,7 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; @@ -70,7 +71,7 @@ import static com.datastax.driver.core.querybuilder.QueryBuilder.eq; */ @Component @Slf4j -@NoSqlDao +@NoSqlTsDao public class CassandraBaseTimeseriesDao extends CassandraAbstractAsyncDao implements TimeseriesDao { private static final int MIN_AGGREGATION_STEP_MS = 1000; @@ -433,14 +434,14 @@ public class CassandraBaseTimeseriesDao extends CassandraAbstractAsyncDao implem public ListenableFuture removeLatest(EntityId entityId, DeleteTsKvQuery query) { ListenableFuture latestEntryFuture = findLatest(entityId, query.getKey()); - ListenableFuture booleanFuture = Futures.transformAsync(latestEntryFuture, latestEntry -> { + ListenableFuture booleanFuture = Futures.transform(latestEntryFuture, latestEntry -> { long ts = latestEntry.getTs(); - if (ts >= query.getStartTs() && ts <= query.getEndTs()) { - return Futures.immediateFuture(true); + if (ts > query.getStartTs() && ts <= query.getEndTs()) { + return true; } else { log.trace("Won't be deleted latest value for [{}], key - {}", entityId, query.getKey()); } - return Futures.immediateFuture(false); + return false; }, readResultsProcessingExecutor); ListenableFuture removedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> { @@ -450,18 +451,34 @@ public class CassandraBaseTimeseriesDao extends CassandraAbstractAsyncDao implem return Futures.immediateFuture(null); }, readResultsProcessingExecutor); - if (query.getRewriteLatestIfDeleted()) { - ListenableFuture savedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> { - if (isRemove) { - return getNewLatestEntryFuture(entityId, query); + final SimpleListenableFuture resultFuture = new SimpleListenableFuture<>(); + Futures.addCallback(removedLatestFuture, new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + if (query.getRewriteLatestIfDeleted()) { + ListenableFuture savedLatestFuture = Futures.transformAsync(booleanFuture, isRemove -> { + if (isRemove) { + return getNewLatestEntryFuture(entityId, query); + } + return Futures.immediateFuture(null); + }, readResultsProcessingExecutor); + + try { + resultFuture.set(savedLatestFuture.get()); + } catch (InterruptedException | ExecutionException e) { + log.warn("Could not get latest saved value for [{}], {}", entityId, query.getKey(), e); + } + } else { + resultFuture.set(null); } - return Futures.immediateFuture(null); - }, readResultsProcessingExecutor); + } - return Futures.transformAsync(Futures.allAsList(Arrays.asList(savedLatestFuture, removedLatestFuture)), - list -> Futures.immediateFuture(null), readResultsProcessingExecutor); - } - return removedLatestFuture; + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to process remove of the latest value", entityId, t); + } + }); + return resultFuture; } private ListenableFuture getNewLatestEntryFuture(EntityId entityId, DeleteTsKvQuery query) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java new file mode 100644 index 0000000000..96d3870dcd --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AbstractBufferedRateExecutor.java @@ -0,0 +1,175 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.util; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import lombok.extern.slf4j.Slf4j; + +import javax.annotation.Nullable; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingDeque; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Created by ashvayka on 24.10.18. + */ +@Slf4j +public abstract class AbstractBufferedRateExecutor, V> implements BufferedRateExecutor { + + private final long maxWaitTime; + private final long pollMs; + private final BlockingQueue> queue; + private final ExecutorService dispatcherExecutor; + private final ExecutorService callbackExecutor; + private final ScheduledExecutorService timeoutExecutor; + private final int concurrencyLimit; + + protected final AtomicInteger concurrencyLevel = new AtomicInteger(); + protected final AtomicInteger totalAdded = new AtomicInteger(); + protected final AtomicInteger totalLaunched = new AtomicInteger(); + protected final AtomicInteger totalReleased = new AtomicInteger(); + protected final AtomicInteger totalFailed = new AtomicInteger(); + protected final AtomicInteger totalExpired = new AtomicInteger(); + protected final AtomicInteger totalRejected = new AtomicInteger(); + + public AbstractBufferedRateExecutor(int queueLimit, int concurrencyLimit, long maxWaitTime, int dispatcherThreads, int callbackThreads, long pollMs) { + this.maxWaitTime = maxWaitTime; + this.pollMs = pollMs; + this.concurrencyLimit = concurrencyLimit; + this.queue = new LinkedBlockingDeque<>(queueLimit); + this.dispatcherExecutor = Executors.newFixedThreadPool(dispatcherThreads); + this.callbackExecutor = Executors.newFixedThreadPool(callbackThreads); + this.timeoutExecutor = Executors.newSingleThreadScheduledExecutor(); + for (int i = 0; i < dispatcherThreads; i++) { + dispatcherExecutor.submit(this::dispatch); + } + } + + @Override + public F submit(T task) { + SettableFuture settableFuture = create(); + F result = wrap(task, settableFuture); + try { + totalAdded.incrementAndGet(); + queue.add(new AsyncTaskContext<>(UUID.randomUUID(), task, settableFuture, System.currentTimeMillis())); + } catch (IllegalStateException e) { + totalRejected.incrementAndGet(); + settableFuture.setException(e); + } + return result; + } + + public void stop() { + if (dispatcherExecutor != null) { + dispatcherExecutor.shutdownNow(); + } + if (callbackExecutor != null) { + callbackExecutor.shutdownNow(); + } + if (timeoutExecutor != null) { + timeoutExecutor.shutdownNow(); + } + } + + protected abstract SettableFuture create(); + + protected abstract F wrap(T task, SettableFuture future); + + protected abstract ListenableFuture execute(AsyncTaskContext taskCtx); + + private void dispatch() { + log.info("Buffered rate executor thread started"); + while (!Thread.interrupted()) { + int curLvl = concurrencyLevel.get(); + AsyncTaskContext taskCtx = null; + try { + if (curLvl <= concurrencyLimit) { + taskCtx = queue.take(); + final AsyncTaskContext finalTaskCtx = taskCtx; + logTask("Processing", finalTaskCtx); + concurrencyLevel.incrementAndGet(); + long timeout = finalTaskCtx.getCreateTime() + maxWaitTime - System.currentTimeMillis(); + if (timeout > 0) { + totalLaunched.incrementAndGet(); + ListenableFuture result = execute(finalTaskCtx); + result = Futures.withTimeout(result, timeout, TimeUnit.MILLISECONDS, timeoutExecutor); + Futures.addCallback(result, new FutureCallback() { + @Override + public void onSuccess(@Nullable V result) { + logTask("Releasing", finalTaskCtx); + totalReleased.incrementAndGet(); + concurrencyLevel.decrementAndGet(); + finalTaskCtx.getFuture().set(result); + } + + @Override + public void onFailure(Throwable t) { + if (t instanceof TimeoutException) { + logTask("Expired During Execution", finalTaskCtx); + } else { + logTask("Failed", finalTaskCtx); + } + totalFailed.incrementAndGet(); + concurrencyLevel.decrementAndGet(); + finalTaskCtx.getFuture().setException(t); + log.debug("[{}] Failed to execute task: {}", finalTaskCtx.getId(), finalTaskCtx.getTask(), t); + } + }, callbackExecutor); + } else { + logTask("Expired Before Execution", finalTaskCtx); + totalExpired.incrementAndGet(); + concurrencyLevel.decrementAndGet(); + taskCtx.getFuture().setException(new TimeoutException()); + } + } else { + Thread.sleep(pollMs); + } + } catch (InterruptedException e) { + break; + } catch (Throwable e) { + if (taskCtx != null) { + log.debug("[{}] Failed to execute task: {}", taskCtx.getId(), taskCtx, e); + totalFailed.incrementAndGet(); + concurrencyLevel.decrementAndGet(); + } else { + log.debug("Failed to queue task:", e); + } + } + } + log.info("Buffered rate executor thread stopped"); + } + + private void logTask(String action, AsyncTaskContext taskCtx) { + if (log.isTraceEnabled()) { + log.trace("[{}] {} task: {}", taskCtx.getId(), action, taskCtx); + } else { + log.debug("[{}] {} task", taskCtx.getId(), action); + } + } + + protected int getQueueSize() { + return queue.size(); + } +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionType.java b/dao/src/main/java/org/thingsboard/server/dao/util/AsyncTask.java similarity index 84% rename from common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionType.java rename to dao/src/main/java/org/thingsboard/server/dao/util/AsyncTask.java index 831682d165..672e4f4656 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/session/SessionType.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AsyncTask.java @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.session; - -public enum SessionType { - - SYNC, ASYNC; +package org.thingsboard.server.dao.util; +/** + * Created by ashvayka on 24.10.18. + */ +public interface AsyncTask { } diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java b/dao/src/main/java/org/thingsboard/server/dao/util/AsyncTaskContext.java similarity index 66% rename from transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java rename to dao/src/main/java/org/thingsboard/server/dao/util/AsyncTaskContext.java index 3211765334..593b3fb04c 100644 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionId.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/AsyncTaskContext.java @@ -13,25 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.transport.http.session; +package org.thingsboard.server.dao.util; -import org.thingsboard.server.common.data.id.SessionId; +import com.google.common.util.concurrent.SettableFuture; +import lombok.Data; import java.util.UUID; /** - * @author Andrew Shvayka + * Created by ashvayka on 24.10.18. */ -public class HttpSessionId implements SessionId { +@Data +public class AsyncTaskContext { private final UUID id; + private final T task; + private final SettableFuture future; + private final long createTime; - public HttpSessionId() { - this.id = UUID.randomUUID(); - } - - @Override - public String toUidStr() { - return id.toString(); - } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateExecutor.java b/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateExecutor.java new file mode 100644 index 0000000000..21f4cd0493 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateExecutor.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.util; + +import com.google.common.util.concurrent.ListenableFuture; + +/** + * Created by ashvayka on 24.10.18. + */ +public interface BufferedRateExecutor { + + F submit(T task); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java b/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java deleted file mode 100644 index 817845b54a..0000000000 --- a/dao/src/main/java/org/thingsboard/server/dao/util/BufferedRateLimiter.java +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.util; - -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.thingsboard.server.dao.exception.BufferLimitException; - -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.Executors; -import java.util.concurrent.LinkedBlockingQueue; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -@Component -@Slf4j -@NoSqlDao -public class BufferedRateLimiter implements AsyncRateLimiter { - - private final ListeningExecutorService pool = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(10)); - - private final int permitsLimit; - private final int maxPermitWaitTime; - private final AtomicInteger permits; - private final BlockingQueue queue; - - private final AtomicInteger maxQueueSize = new AtomicInteger(); - private final AtomicInteger maxGrantedPermissions = new AtomicInteger(); - private final AtomicInteger totalGranted = new AtomicInteger(); - private final AtomicInteger totalReleased = new AtomicInteger(); - private final AtomicInteger totalRequested = new AtomicInteger(); - - public BufferedRateLimiter(@Value("${cassandra.query.buffer_size}") int queueLimit, - @Value("${cassandra.query.concurrent_limit}") int permitsLimit, - @Value("${cassandra.query.permit_max_wait_time}") int maxPermitWaitTime) { - this.permitsLimit = permitsLimit; - this.maxPermitWaitTime = maxPermitWaitTime; - this.permits = new AtomicInteger(); - this.queue = new LinkedBlockingQueue<>(queueLimit); - } - - @Override - public ListenableFuture acquireAsync() { - totalRequested.incrementAndGet(); - if (queue.isEmpty()) { - if (permits.incrementAndGet() <= permitsLimit) { - if (permits.get() > maxGrantedPermissions.get()) { - maxGrantedPermissions.set(permits.get()); - } - totalGranted.incrementAndGet(); - return Futures.immediateFuture(null); - } - permits.decrementAndGet(); - } - - return putInQueue(); - } - - @Override - public void release() { - permits.decrementAndGet(); - totalReleased.incrementAndGet(); - reprocessQueue(); - } - - private void reprocessQueue() { - while (permits.get() < permitsLimit) { - if (permits.incrementAndGet() <= permitsLimit) { - if (permits.get() > maxGrantedPermissions.get()) { - maxGrantedPermissions.set(permits.get()); - } - LockedFuture lockedFuture = queue.poll(); - if (lockedFuture != null) { - totalGranted.incrementAndGet(); - lockedFuture.latch.countDown(); - } else { - permits.decrementAndGet(); - break; - } - } else { - permits.decrementAndGet(); - } - } - } - - private LockedFuture createLockedFuture() { - CountDownLatch latch = new CountDownLatch(1); - ListenableFuture future = pool.submit(() -> { - latch.await(); - return null; - }); - return new LockedFuture(latch, future, System.currentTimeMillis()); - } - - private ListenableFuture putInQueue() { - - int size = queue.size(); - if (size > maxQueueSize.get()) { - maxQueueSize.set(size); - } - - if (queue.remainingCapacity() > 0) { - try { - LockedFuture lockedFuture = createLockedFuture(); - if (!queue.offer(lockedFuture, 1, TimeUnit.SECONDS)) { - lockedFuture.cancelFuture(); - return Futures.immediateFailedFuture(new BufferLimitException()); - } - if(permits.get() < permitsLimit) { - reprocessQueue(); - } - if(permits.get() < permitsLimit) { - reprocessQueue(); - } - return lockedFuture.future; - } catch (InterruptedException e) { - return Futures.immediateFailedFuture(new BufferLimitException()); - } - } - return Futures.immediateFailedFuture(new BufferLimitException()); - } - - @Scheduled(fixedDelayString = "${cassandra.query.rate_limit_print_interval_ms}") - public void printStats() { - int expiredCount = 0; - for (LockedFuture lockedFuture : queue) { - if (lockedFuture.isExpired()) { - lockedFuture.cancelFuture(); - expiredCount++; - } - } - log.info("Permits maxBuffer [{}] maxPermits [{}] expired [{}] currPermits [{}] currBuffer [{}] " + - "totalPermits [{}] totalRequests [{}] totalReleased [{}]", - maxQueueSize.getAndSet(0), maxGrantedPermissions.getAndSet(0), expiredCount, - permits.get(), queue.size(), - totalGranted.getAndSet(0), totalRequested.getAndSet(0), totalReleased.getAndSet(0)); - } - - private class LockedFuture { - final CountDownLatch latch; - final ListenableFuture future; - final long createTime; - - public LockedFuture(CountDownLatch latch, ListenableFuture future, long createTime) { - this.latch = latch; - this.future = future; - this.createTime = createTime; - } - - void cancelFuture() { - future.cancel(false); - latch.countDown(); - } - - boolean isExpired() { - return (System.currentTimeMillis() - createTime) > maxPermitWaitTime; - } - - } - - -} diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java new file mode 100644 index 0000000000..a8049eec5f --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlAnyDao.java @@ -0,0 +1,22 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.util; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; + +@ConditionalOnExpression("'${database.ts.type}'=='cassandra' || '${database.entities.type}'=='cassandra'") +public @interface NoSqlAnyDao { +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlDao.java index 96dbdab758..c3a719d893 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlDao.java @@ -17,6 +17,6 @@ package org.thingsboard.server.dao.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -@ConditionalOnProperty(prefix = "database", value = "type", havingValue = "cassandra") +@ConditionalOnProperty(prefix = "database.entities", value = "type", havingValue = "cassandra") public @interface NoSqlDao { } diff --git a/application/src/main/proto/discovery.proto b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlTsDao.java similarity index 72% rename from application/src/main/proto/discovery.proto rename to dao/src/main/java/org/thingsboard/server/dao/util/NoSqlTsDao.java index be4ec92d2f..748365fe41 100644 --- a/application/src/main/proto/discovery.proto +++ b/dao/src/main/java/org/thingsboard/server/dao/util/NoSqlTsDao.java @@ -13,14 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -syntax = "proto3"; -package discovery; +package org.thingsboard.server.dao.util; -option java_package = "org.thingsboard.server.gen.discovery"; -option java_outer_classname = "ServerInstanceProtos"; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -message ServerInfo { - string host = 1; - int32 port = 2; - int64 ts = 3; +@ConditionalOnProperty(prefix = "database.ts", value = "type", havingValue = "cassandra") +public @interface NoSqlTsDao { } diff --git a/dao/src/main/java/org/thingsboard/server/dao/util/SqlDao.java b/dao/src/main/java/org/thingsboard/server/dao/util/SqlDao.java index 3986f022d9..ab39c847b6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/util/SqlDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/SqlDao.java @@ -17,6 +17,6 @@ package org.thingsboard.server.dao.util; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -@ConditionalOnProperty(prefix = "database", value = "type", havingValue = "sql") +@ConditionalOnProperty(prefix = "database.entities", value = "type", havingValue = "sql") public @interface SqlDao { } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesResponse.java b/dao/src/main/java/org/thingsboard/server/dao/util/SqlTsDao.java similarity index 72% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesResponse.java rename to dao/src/main/java/org/thingsboard/server/dao/util/SqlTsDao.java index 6242d46e8f..0470486408 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/GetAttributesResponse.java +++ b/dao/src/main/java/org/thingsboard/server/dao/util/SqlTsDao.java @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; +package org.thingsboard.server.dao.util; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; - -public interface GetAttributesResponse extends ResponseMsg { +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +@ConditionalOnProperty(prefix = "database.ts", value = "type", havingValue = "sql") +public @interface SqlTsDao { } diff --git a/dao/src/main/resources/cassandra/schema.cql b/dao/src/main/resources/cassandra/schema-entities.cql similarity index 84% rename from dao/src/main/resources/cassandra/schema.cql rename to dao/src/main/resources/cassandra/schema-entities.cql index f03122ab6b..ef27c70625 100644 --- a/dao/src/main/resources/cassandra/schema.cql +++ b/dao/src/main/resources/cassandra/schema-entities.cql @@ -165,35 +165,55 @@ CREATE TABLE IF NOT EXISTS thingsboard.device ( CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_and_name AS SELECT * from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND name IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL PRIMARY KEY ( tenant_id, name, id, customer_id, type) WITH CLUSTERING ORDER BY ( name ASC, id DESC, customer_id DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_and_search_text AS SELECT * from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL PRIMARY KEY ( tenant_id, search_text, id, customer_id, type) WITH CLUSTERING ORDER BY ( search_text ASC, id DESC, customer_id DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_tenant_by_type_and_search_text AS SELECT * from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL PRIMARY KEY ( tenant_id, type, search_text, id, customer_id) WITH CLUSTERING ORDER BY ( type ASC, search_text ASC, id DESC, customer_id DESC); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_customer_and_search_text AS SELECT * from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL PRIMARY KEY ( customer_id, tenant_id, search_text, id, type ) WITH CLUSTERING ORDER BY ( tenant_id DESC, search_text ASC, id DESC ); CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.device_by_customer_by_type_and_search_text AS SELECT * from thingsboard.device - WHERE tenant_id IS NOT NULL AND customer_id IS NOT NULL AND type IS NOT NULL AND search_text IS NOT NULL AND id IS NOT NULL + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL PRIMARY KEY ( customer_id, tenant_id, type, search_text, id ) WITH CLUSTERING ORDER BY ( tenant_id DESC, type ASC, search_text ASC, id DESC ); @@ -378,41 +398,6 @@ CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.dashboard_by_tenant_and_searc PRIMARY KEY ( tenant_id, search_text, id ) WITH CLUSTERING ORDER BY ( search_text ASC, id DESC ); -CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_cf ( - entity_type text, // (DEVICE, CUSTOMER, TENANT) - entity_id timeuuid, - key text, - partition bigint, - ts bigint, - bool_v boolean, - str_v text, - long_v bigint, - dbl_v double, - PRIMARY KEY (( entity_type, entity_id, key, partition ), ts) -); - -CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_partitions_cf ( - entity_type text, // (DEVICE, CUSTOMER, TENANT) - entity_id timeuuid, - key text, - partition bigint, - PRIMARY KEY (( entity_type, entity_id, key ), partition) -) WITH CLUSTERING ORDER BY ( partition ASC ) - AND compaction = { 'class' : 'LeveledCompactionStrategy' }; - -CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_latest_cf ( - entity_type text, // (DEVICE, CUSTOMER, TENANT) - entity_id timeuuid, - key text, - ts bigint, - bool_v boolean, - str_v text, - long_v bigint, - dbl_v double, - PRIMARY KEY (( entity_type, entity_id ), key) -) WITH compaction = { 'class' : 'LeveledCompactionStrategy' }; - - CREATE TABLE IF NOT EXISTS thingsboard.attributes_kv_cf ( entity_type text, // (DEVICE, CUSTOMER, TENANT) entity_id timeuuid, @@ -638,3 +623,91 @@ CREATE TABLE IF NOT EXISTS thingsboard.rule_node ( additional_info text, PRIMARY KEY (id) ); + +CREATE TABLE IF NOT EXISTS thingsboard.entity_view ( + id timeuuid, + entity_id timeuuid, + entity_type text, + tenant_id timeuuid, + customer_id timeuuid, + name text, + type text, + keys text, + start_ts bigint, + end_ts bigint, + search_text text, + additional_info text, + PRIMARY KEY (id, entity_id, tenant_id, customer_id, type) +); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_name AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND name IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, name, id, customer_id, entity_id, type) + WITH CLUSTERING ORDER BY (name ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_search_text AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, search_text, id, customer_id, entity_id, type) + WITH CLUSTERING ORDER BY (search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_by_type_and_search_text AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND entity_id IS NOT NULL + AND customer_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, type, search_text, id, customer_id, entity_id) + WITH CLUSTERING ORDER BY (type ASC, search_text ASC, id DESC, customer_id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, customer_id, search_text, id, entity_id, type) + WITH CLUSTERING ORDER BY (customer_id DESC, search_text ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_customer_and_type AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, type, customer_id, search_text, id, entity_id) + WITH CLUSTERING ORDER BY (type ASC, customer_id DESC, search_text ASC, id DESC); + +CREATE MATERIALIZED VIEW IF NOT EXISTS thingsboard.entity_view_by_tenant_and_entity_id AS + SELECT * + from thingsboard.entity_view + WHERE tenant_id IS NOT NULL + AND customer_id IS NOT NULL + AND entity_id IS NOT NULL + AND type IS NOT NULL + AND search_text IS NOT NULL + AND id IS NOT NULL + PRIMARY KEY (tenant_id, entity_id, customer_id, search_text, id, type) + WITH CLUSTERING ORDER BY (entity_id DESC, customer_id DESC, search_text ASC, id DESC); \ No newline at end of file diff --git a/dao/src/main/resources/cassandra/schema-ts.cql b/dao/src/main/resources/cassandra/schema-ts.cql new file mode 100644 index 0000000000..a5c5ec2ff1 --- /dev/null +++ b/dao/src/main/resources/cassandra/schema-ts.cql @@ -0,0 +1,55 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +CREATE KEYSPACE IF NOT EXISTS thingsboard +WITH replication = { + 'class' : 'SimpleStrategy', + 'replication_factor' : 1 +}; + +CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_cf ( + entity_type text, // (DEVICE, CUSTOMER, TENANT) + entity_id timeuuid, + key text, + partition bigint, + ts bigint, + bool_v boolean, + str_v text, + long_v bigint, + dbl_v double, + PRIMARY KEY (( entity_type, entity_id, key, partition ), ts) +); + +CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_partitions_cf ( + entity_type text, // (DEVICE, CUSTOMER, TENANT) + entity_id timeuuid, + key text, + partition bigint, + PRIMARY KEY (( entity_type, entity_id, key ), partition) +) WITH CLUSTERING ORDER BY ( partition ASC ) + AND compaction = { 'class' : 'LeveledCompactionStrategy' }; + +CREATE TABLE IF NOT EXISTS thingsboard.ts_kv_latest_cf ( + entity_type text, // (DEVICE, CUSTOMER, TENANT) + entity_id timeuuid, + key text, + ts bigint, + bool_v boolean, + str_v text, + long_v bigint, + dbl_v double, + PRIMARY KEY (( entity_type, entity_id ), key) +) WITH compaction = { 'class' : 'LeveledCompactionStrategy' }; diff --git a/dao/src/main/resources/cassandra/system-data.cql b/dao/src/main/resources/cassandra/system-data.cql index 16a53cad5c..3a0a9fd8e8 100644 --- a/dao/src/main/resources/cassandra/system-data.cql +++ b/dao/src/main/resources/cassandra/system-data.cql @@ -41,242 +41,4 @@ VALUES ( now ( ), 'mail', '{ "enableTls": "false", "username": "", "password": "" -}' ); - -/** System widgets library **/ -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', 'gpio widgets', 'GPIO widgets' ); - -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'maps', 'Maps' ); - -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'digital gauges', 'Digital gauges' ); - -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'charts', 'Charts' ); - -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'cards', 'Cards' ); - -INSERT INTO "thingsboard"."widgets_bundle" ( "id", "tenant_id", "alias", "search_text", "title" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', 'analogue gauges', 'Analogue gauges' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'timeseries', -'{"type":"timeseries","sizeX":8,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.4.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n \n var lineData = {\n labels: [],\n datasets: []\n };\n \n for (var i=0; i < self.ctx.data.length; i++) {\n var dataKey = self.ctx.data[i].dataKey;\n var keySettings = dataKey.settings;\n var backgroundColor = tinycolor(dataKey.color);\n backgroundColor.setAlpha(0.4);\n var dataset = {\n label: dataKey.label,\n data: [],\n borderColor: dataKey.color,\n borderWidth: 2,\n backgroundColor: backgroundColor.toRgbString(),\n pointRadius: keySettings.showPoints ? 1 : 0,\n fill: keySettings.fillLines || false,\n showLine: keySettings.showLines || true,\n spanGaps: false,\n lineTension: angular.isDefined(keySettings.tension) ? keySettings.tension : 0.2\n }\n lineData.datasets.push(dataset);\n }\n\n var ctx = $(''#lineChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''line'',\n data: lineData,\n options: {\n responsive: false,\n tooltips: {\n mode: ''index'',\n inersect: true\n },\n hover: {\n mode: ''index'',\n inersect: true\n },\n maintainAspectRatio: false,\n /*animation: {\n duration: 200,\n easing: ''linear''\n },*/\n elements: {\n line: {\n tension: 0.2\n } \n },\n scales: {\n xAxes: [{\n type: ''time'',\n ticks: {\n maxRotation: 20,\n autoSkip: true\n },\n time: {\n displayFormats: {\n second: ''hh:mm:ss'',\n minute: ''hh:mm:ss''\n }\n }\n }]\n },\n zoom: {\n onSelect: function(startTimeMs, endTimeMs) {\n self.ctx.timewindowFunctions.onUpdateTimewindow(startTimeMs, endTimeMs);\n },\n onResetSelect: function() {\n self.ctx.timewindowFunctions.onResetTimewindow();\n }\n }\n }\n });\n \n self.onResize();\n \n}\n\nself.onDataUpdated = function() {\n \n if (self.ctx.chart.zoom.isMouseInteraction) {\n return;\n }\n if (!self.ctx.tickUpdate) {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var dataSetData = [];\n var dataKeyData = self.ctx.data[i].data;\n for (var i2 = 0; i2 < dataKeyData.length; i2 ++) {\n dataSetData.push({x: moment(dataKeyData[i2][0]), y: dataKeyData[i2][1]});\n \n }\n self.ctx.chart.data.datasets[i].data = dataSetData; \n }\n }\n \n self.ctx.chart.options.scales.xAxes[0].time.min = moment(self.ctx.timeWindow.minTime);\n self.ctx.chart.options.scales.xAxes[0].time.max = moment(self.ctx.timeWindow.maxTime);\n \n self.ctx.chart.update(0, true);\n\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n self.ctx.chart.update(0, true);\n}\n\nself.onDestroy = function() {\n}\n\nfunction getYAxis(chartInstance) {\n var scales = chartInstance.scales;\n for (var scaleId in scales) {\n\t var scale = scales[scaleId];\n\n\t if (!scale.isHorizontal()) {\n\t\t return scale;\n\t }\n }\n}\n\nfunction getXAxis(chartInstance) {\n var scales = chartInstance.scales;\n for (var scaleId in scales) {\n\t var scale = scales[scaleId];\n\n\t if (scale.isHorizontal()) {\n\t\t return scale;\n\t }\n }\n}\n\nfunction eventPointer (e) {\n if (angular.isDefined(e.touches) && e.touches.length > 0) {\n return {\n x : e.touches[0].pageX,\n y : e.touches[0].pageY\n };\n } else if (angular.isDefined(e.changedTouches) && e.changedTouches.length > 0) {\n return {\n x : e.changedTouches[0].pageX,\n y : e.changedTouches[0].pageY\n };\n } else if (e.pageX || e.pageY) {\n return {\n x : e.pageX,\n y : e.pageY\n };\n } else if (e.clientX || e.clientY) {\n var\n d = document,\n b = d.body,\n de = d.documentElement;\n return {\n x: e.clientX + b.scrollLeft + de.scrollLeft,\n y: e.clientY + b.scrollTop + de.scrollTop\n };\n }\n}\n\nvar zoomPlugin = {\n beforeInit: function(chartInstance) {\n chartInstance.zoom = {};\n var node = chartInstance.zoom.node = chartInstance.chart.ctx.canvas;\n \n chartInstance.zoom.mouseDownHandler = function(event) {\n chartInstance.zoom.dragZoomStart = event;\n chartInstance.zoom.dragZoomStartPointer = eventPointer(event);\n chartInstance.zoom.isMouseInteraction = true;\n };\n\n node.addEventListener(''mousedown'', chartInstance.zoom.mouseDownHandler);\n \n chartInstance.zoom.mouseMoveHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n chartInstance.update(0);\n chartInstance.zoom.dragZoomEnd = event;\n chartInstance.zoom.dragZoomEndPointer = eventPointer(event);\n }\n };\n \n node.addEventListener(''mousemove'', chartInstance.zoom.mouseMoveHandler);\n \n chartInstance.zoom.mouseUpHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \n var chartArea = chartInstance.chartArea;\n var yAxis = getYAxis(chartInstance);\n\t\t\t\t\tvar beginPoint = chartInstance.zoom.dragZoomStart;\n\t\t\t\t\tvar beginPointer = chartInstance.zoom.dragZoomStartPointer;\n\t\t\t\t\tvar upEventPointer = eventPointer(event);\n\t\t\t\t\tvar offsetX = beginPoint.target.getBoundingClientRect().left;\n\t\t\t\t\tvar startX = Math.min(beginPointer.x, upEventPointer.x) - offsetX;\n\t\t\t\t\tvar endX = Math.max(beginPointer.x, upEventPointer.x) - offsetX;\n\t\t\t\t\tvar dragDistance = endX - startX;\n\t\t\t\t\t\n\t\t\t\t\tif (dragDistance > 0) {\n \t\t\t\t\tvar xAxis = getXAxis(chartInstance);\n \t\t\t\t\tvar options = chartInstance.options;\n \t\t\t\t\tif (options.scales.xAxes[0].time) {\n \t\t\t\t\t startX = Math.max(startX, xAxis.left);\n \t\t\t\t\t endX = Math.min(endX, xAxis.right);\n \t\t\t\t\t if (endX - startX > 0) {\n \t\t\t\t\t startX = startX - xAxis.left;\n \t\t\t\t\t endX = endX - xAxis.left;\n \t\t\t\t\t var time = options.scales.xAxes[0].time;\n \t\t\t\t\t var min = time.min.valueOf();\n \t\t\t\t\t var max = time.max.valueOf();\n \t\t\t\t\t var axisDistance = xAxis.right - xAxis.left;\n \t\t\t\t\t var timeDistance = max - min;\n \t\t\t\t\t \n \t\t\t\t\t var zoomStartTime = min + startX / axisDistance * timeDistance;\n \t\t\t\t\t var zoomEndTime = min + endX / axisDistance * timeDistance;\n\n \t\t\t\t\t if (options.zoom && options.zoom.onSelect) {\n \t\t\t\t\t options.zoom.onSelect(zoomStartTime, zoomEndTime);\n \t\t\t\t\t }\n \t\t\t\t\t }\n \t\t\t\t\t}\n\t\t\t\t\t}\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n chartInstance.zoom.isMouseInteraction = false;\n };\n \n node.addEventListener(''mouseup'', chartInstance.zoom.mouseUpHandler);\n \n chartInstance.zoom.mouseLeaveHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n chartInstance.zoom.isMouseInteraction = false;\n };\n \n node.addEventListener(''mouseleave'', chartInstance.zoom.mouseLeaveHandler);\n \n chartInstance.zoom.dblClickHandler = function(event) {\n if (chartInstance.zoom.dragZoomStart) {\n \t\t\tchartInstance.zoom.dragZoomStart = null;\n \t\t\tchartInstance.zoom.dragZoomEnd = null; \n }\n var options = chartInstance.options;\n if (options.zoom && options.zoom.onResetSelect) {\n options.zoom.onResetSelect();\n }\n };\n \n node.addEventListener(''dblclick'', chartInstance.zoom.dblClickHandler);\n },\n beforeDatasetsDraw: function(chartInstance, easing) {\n \t\tvar ctx = chartInstance.chart.ctx;\n \t\tvar chartArea = chartInstance.chartArea;\n \t\tctx.save();\n \t\tctx.beginPath();\n \t\tif (chartInstance.zoom && chartInstance.zoom.dragZoomEnd) {\n \t\t\tvar yAxis = getYAxis(chartInstance);\n \t\t\tvar beginPoint = chartInstance.zoom.dragZoomStart;\n \t\t\tvar beginPointer = chartInstance.zoom.dragZoomStartPointer;\n \t\t\tvar endPoint = chartInstance.zoom.dragZoomEnd;\n \t\t\tvar endPointer = chartInstance.zoom.dragZoomEndPointer;\n \t\t\t\n \t\t\tvar offsetX = beginPoint.target.getBoundingClientRect().left;\n \t\t\tvar startX = Math.min(beginPointer.x, endPointer.x) - offsetX;\n \t\t\tvar endX = Math.max(beginPointer.x, endPointer.x) - offsetX;\n \t\t\tvar rectWidth = endX - startX;\n \t\t\tctx.fillStyle = ''rgba(157,203,255,0.1)'';\n \t\t\tctx.lineWidth = 1;\n \t\t\tctx.strokeRect(startX, yAxis.top, rectWidth, yAxis.bottom - yAxis.top);\n \t\t\tctx.fillRect(startX, yAxis.top, rectWidth, yAxis.bottom - yAxis.top);\n \t\t}\n \t\tif (chartArea) {\n \t\t ctx.rect(chartArea.left, chartArea.top, chartArea.right - chartArea.left, chartArea.bottom - chartArea.top);\n \t\t}\n\t\t ctx.clip(); \n },\n \n afterDatasetsDraw: function(chartInstance) {\n\t chartInstance.chart.ctx.restore();\n },\n \n destroy: function(chartInstance) {\n if (chartInstance.zoom) {\n var node = chartInstance.zoom.node;\n\t\t\t\tnode.removeEventListener(''mousedown'', chartInstance.zoom.mouseDownHandler);\n\t\t\t\tnode.removeEventListener(''mousemove'', chartInstance.zoom.mouseMoveHandler);\n\t\t\t\tnode.removeEventListener(''mouseup'', chartInstance.zoom.mouseUpHandler);\n\t\t\t\tnode.removeEventListener(''mouseleave'', chartInstance.zoom.mouseLeaveHandler);\t \n\t\t\t\tnode.removeEventListener(''dblclick'', chartInstance.zoom.dblClickHandler);\n\t\t\t\tdelete chartInstance.zoom;\n }\n }\n };\n\nChart.pluginService.register(zoomPlugin);\n","settingsSchema":"{}","dataKeySettingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"showLines\": {\n \"title\": \"Show lines\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"fillLines\": {\n \"title\": \"Fill lines\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"showPoints\": {\n \"title\": \"Show points\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"tension\": {\n \"title\": \"Line tension\",\n \"type\": \"number\",\n \"default\": 0.2\n }\n },\n \"required\": [\"showLines\", \"fillLines\", \"showPoints\"]\n },\n \"form\": [\n \"showLines\",\n \"fillLines\",\n \"showPoints\",\n \"tension\"\n ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.5644745944820795,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.18379294198604845,\"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;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Timeseries - Chart.js (Deprecated)\"}"}', -'Timeseries - Chart.js (Deprecated)' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'digital_vertical_bar', -'{"type":"latest","sizeX":2.5,"sizeY":4.5,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":60,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[\"#3d5afe\",\"#f44336\"],\"refreshAnimationType\":\"<>\",\"refreshAnimationTime\":700,\"startAnimationType\":\"<>\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":14},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":8,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#cccccc\"},\"neonGlowBrightness\":20,\"decimals\":0,\"showUnitTitle\":true,\"gaugeColor\":\"#171a1c\",\"gaugeType\":\"verticalBar\",\"showTitle\":false,\"units\":\"°C\",\"minValue\":-60,\"dashThickness\":1.2},\"title\":\"Digital vertical bar\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Digital vertical bar' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', 'gpio_panel', -'{"type":"latest","sizeX":5,"sizeY":2,"resources":[],"templateHtml":"
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n
","templateCss":".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.gpio-panel tb-led-light > div {\n margin: auto;\n}\n\n.led-panel {\n margin: 0;\n width: 66px;\n min-width: 66px;\n}\n\n.led-container {\n width: 48px;\n min-width: 48px;\n}\n\n.pin {\n margin-top: auto;\n margin-bottom: auto;\n color: white;\n font-size: 12px;\n width: 16px;\n min-width: 16px;\n}\n\n.led-panel.col-0 .pin {\n margin-left: auto;\n padding-left: 2px;\n text-align: right;\n}\n\n.led-panel.col-1 .pin {\n margin-right: auto;\n \n text-align: left;\n}\n\n.gpio-left-label {\n margin-right: 8px;\n}\n\n.gpio-right-label {\n margin-left: 8px;\n}","controllerScript":"self.onInit = function() {\n var i, gpio;\n \n var scope = self.ctx.$scope;\n var settings = self.ctx.settings;\n \n scope.gpioList = [];\n scope.gpioByPin = {};\n for (var g = 0; g < settings.gpioList.length; g++) {\n gpio = settings.gpioList[g];\n scope.gpioList.push(\n {\n row: gpio.row,\n col: gpio.col,\n pin: gpio.pin,\n label: gpio.label,\n enabled: false,\n colorOn: tinycolor(gpio.color).lighten(20).toHexString(),\n colorOff: tinycolor(gpio.color).darken().toHexString()\n }\n );\n scope.gpioByPin[gpio.pin] = scope.gpioList[scope.gpioList.length-1];\n }\n\n scope.ledPanelBackgroundColor = settings.ledPanelBackgroundColor || tinycolor(''green'').lighten(2).toRgbString();\n\n scope.gpioCells = {};\n var rowCount = 0;\n for (i = 0; i < scope.gpioList.length; i++) {\n gpio = scope.gpioList[i];\n scope.gpioCells[gpio.row+''_''+gpio.col] = gpio;\n rowCount = Math.max(rowCount, gpio.row+1);\n }\n \n scope.prefferedRowHeight = 32;\n scope.rows = [];\n for (i = 0; i < rowCount; i++) {\n var row = [];\n for (var c =0; c<2;c++) {\n if (scope.gpioCells[i+''_''+c]) {\n row[c] = scope.gpioCells[i+''_''+c];\n } else {\n row[c] = null;\n }\n }\n scope.rows.push(row);\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n var changed = false;\n for (var d = 0; d < self.ctx.data.length; d++) {\n var cellData = self.ctx.data[d];\n var dataKey = cellData.dataKey;\n var gpio = self.ctx.$scope.gpioByPin[dataKey.label];\n if (gpio) {\n var enabled = false;\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n enabled = (tvPair[1] === true || tvPair[1] === ''true'');\n }\n if (gpio.enabled != enabled) {\n changed = true;\n gpio.enabled = enabled;\n }\n }\n }\n if (changed) {\n self.ctx.$scope.$digest();\n } \n}\n\nself.onResize = function() {\n var rowCount = self.ctx.$scope.rows.length;\n var prefferedRowHeight = (self.ctx.height - 35)/rowCount;\n prefferedRowHeight = Math.min(32, prefferedRowHeight);\n prefferedRowHeight = Math.max(12, prefferedRowHeight);\n self.ctx.$scope.prefferedRowHeight = prefferedRowHeight;\n \n var ratio = prefferedRowHeight/32;\n \n var leftLabels = $(''.gpio-left-label'', self.ctx.$container);\n leftLabels.css(''font-size'', 16*ratio+''px'');\n var rightLabels = $(''.gpio-right-label'', self.ctx.$container);\n rightLabels.css(''font-size'', 16*ratio+''px'');\n var pins = $(''.pin'', self.ctx.$container);\n var pinsFontSize = Math.max(9, 12*ratio);\n pins.css(''font-size'', pinsFontSize+''px''); \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"gpioList\": {\n \"title\": \"Gpio leds\",\n \"type\": \"array\",\n \"minItems\" : 1,\n \"items\": {\n \"title\": \"Gpio led\",\n \"type\": \"object\",\n \"properties\": {\n \"pin\": {\n \"title\": \"Pin\",\n \"type\": \"number\"\n },\n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"row\": {\n \"title\": \"Row\",\n \"type\": \"number\"\n },\n \"col\": {\n \"title\": \"Column\",\n \"type\": \"number\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\",\n \"default\": \"red\"\n }\n },\n \"required\": [\"pin\", \"label\", \"row\", \"col\", \"color\"]\n }\n },\n \"ledPanelBackgroundColor\": {\n \"title\": \"LED panel background color\",\n \"type\": \"string\",\n \"default\": \"#008a00\"\n } \n },\n \"required\": [\"gpioList\", \n \"ledPanelBackgroundColor\"]\n },\n \"form\": [\n {\n \"key\": \"gpioList\",\n \"items\": [\n \"gpioList[].pin\",\n \"gpioList[].label\",\n \"gpioList[].row\",\n \"gpioList[].col\",\n {\n \"key\": \"gpioList[].color\",\n \"type\": \"color\"\n }\n ]\n },\n {\n \"key\": \"ledPanelBackgroundColor\",\n \"type\": \"color\"\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"gpioList\":[{\"pin\":1,\"label\":\"GPIO 1\",\"row\":0,\"col\":0,\"color\":\"#008000\",\"_uniqueKey\":0},{\"pin\":2,\"label\":\"GPIO 2\",\"row\":0,\"col\":1,\"color\":\"#ffff00\",\"_uniqueKey\":1},{\"pin\":3,\"label\":\"GPIO 3\",\"row\":1,\"col\":0,\"color\":\"#cf006f\",\"_uniqueKey\":2}],\"ledPanelBackgroundColor\":\"#b71c1c\"},\"title\":\"Basic GPIO Panel\",\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"1\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.22518255793320163,\"funcBody\":\"var period = time % 1500;\\nreturn period < 500;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"2\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.7008206860666621,\"funcBody\":\"var period = time % 1500;\\nreturn period >= 500 && period < 1000;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"3\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.42600325102193426,\"funcBody\":\"var period = time % 1500;\\nreturn period >= 1000;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}}}"}', -'Basic GPIO Panel' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'timeseries_table', -'{"type":"timeseries","sizeX":8,"sizeY":6.5,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n \n var scope = self.ctx.$scope;\n var id = self.ctx.$scope.$injector.get(''utils'').guid();\n\n scope.config = {\n settings: self.ctx.settings,\n widgetConfig: self.ctx.widgetConfig\n }\n\n scope.datasources = self.ctx.datasources;\n scope.data = self.ctx.data;\n scope.tableId = \"table-\"+id;\n \n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.data = self.ctx.data;\n self.ctx.$scope.$broadcast(''timeseries-table-data-updated'', self.ctx.$scope.tableId);\n}\n\nself.onDestroy = function() {\n}","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"TimeseriesTableSettings\",\n \"properties\": {\n \"showTimestamp\": {\n \"title\": \"Display timestamp column\",\n \"type\": \"boolean\",\n \"default\": true\n }\n },\n \"required\": []\n },\n \"form\": [\n \"showTimestamp\"\n ]\n}","dataKeySettingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"DataKeySettings\",\n \"properties\": {\n \"useCellStyleFunction\": {\n \"title\": \"Use cell style function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellStyleFunction\": {\n \"title\": \"Cell style function: f(value)\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"useCellContentFunction\": {\n \"title\": \"Use cell content function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"cellContentFunction\": {\n \"title\": \"Cell content function: f(value, rowData, filter)\",\n \"type\": \"string\",\n \"default\": \"\"\n }\n },\n \"required\": []\n },\n \"form\": [\n \"useCellStyleFunction\",\n {\n \"key\": \"cellStyleFunction\",\n \"type\": \"javascript\"\n },\n \"useCellContentFunction\",\n {\n \"key\": \"cellContentFunction\",\n \"type\": \"javascript\"\n }\n ]\n}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature °C\",\"color\":\"#2196f3\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = (value + 60)/120 * 100;\\n var color = tinycolor.mix(''blue'', ''red'', amount = percent);\\n color.setAlpha(.5);\\n return {\\n paddingLeft: ''20px'',\\n color: ''#ffffff'',\\n background: color.toRgbString(),\\n fontSize: ''18px''\\n };\\n} else {\\n return {};\\n}\"},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity, %\",\"color\":\"#ffc107\",\"settings\":{\"useCellStyleFunction\":true,\"cellStyleFunction\":\"if (value) {\\n var percent = value;\\n var backgroundColor = tinycolor(''blue'');\\n backgroundColor.setAlpha(value/100);\\n var color = ''blue'';\\n if (value > 50) {\\n color = ''white'';\\n }\\n \\n return {\\n paddingLeft: ''20px'',\\n color: color,\\n background: backgroundColor.toRgbString(),\\n fontSize: ''18px''\\n };\\n} else {\\n return {};\\n}\",\"useCellContentFunction\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nvar multiplier = Math.pow(10, 1 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 5) {\\n\\tvalue = 5;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"interval\":1000,\"timewindowMs\":60000},\"aggregation\":{\"type\":\"NONE\",\"limit\":200}},\"showTitle\":true,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"showTimestamp\":true},\"title\":\"Timeseries table\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":false,\"showLegend\":false}"}', -'Timeseries table' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'polar_area_chart_js', -'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n pieData.datasets.push(dataset);\n \n for (var i = 0; i < self.ctx.data.length; i++) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n var borderColor = tinycolor(dataKey.color).darken();\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(''#fff'');\n dataset.borderWidth.push(5);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''polarArea'',\n data: pieData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n \n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n if (self.ctx.height >= 70) {\n try {\n self.ctx.chart.resize();\n } catch (e) {}\n }\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fifth\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.2074391823443591,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Polar Area - Chart.js\"}"}', -'Polar Area - Chart.js' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'html_card', -'{"type":"static","sizeX":7.5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"","controllerScript":"self.onInit = function() {\n\n var cssParser = new cssjs();\n cssParser.testMode = false;\n var namespace = ''html-card-'' + hashCode(self.ctx.settings.cardCss);\n cssParser.cssPreviewNamespace = namespace;\n cssParser.createStyleElement(namespace, self.ctx.settings.cardCss);\n self.ctx.$container.addClass(namespace);\n cardHtml = self.ctx.settings.cardHtml;\n self.ctx.$container.html(cardHtml);\n \n function hashCode(str) {\n var hash = 0;\n var i, char;\n if (str.length === 0) return hash;\n for (i = 0; i < str.length; i++) {\n char = str.charCodeAt(i);\n hash = ((hash << 5) - hash) + char;\n hash = hash & hash;\n }\n return hash;\n }\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"required\": [\"cardHtml\"],\n \"properties\": {\n \"cardCss\": {\n \"title\": \"CSS\",\n \"type\": \"string\",\n \"default\": \".card {\\n font-weight: bold; \\n}\"\n },\n \"cardHtml\": {\n \"title\": \"HTML\",\n \"type\": \"string\",\n \"default\": \"
HTML code here
\"\n }\n }\n },\n \"form\": [\n {\n \"key\": \"cardCss\",\n \"type\": \"css\"\n }, \n {\n \"key\": \"cardHtml\",\n \"type\": \"html\"\n } \n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"static\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"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;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"cardHtml\":\"
HTML code here
\",\"cardCss\":\".card {\\n font-weight: bold;\\n font-size: 32px;\\n color: #999;\\n width: 100%;\\n height: 100%;\\n display: flex;\\n align-items: center;\\n justify-content: center;\\n}\"},\"title\":\"HTML Card\",\"dropShadow\":true}"}', -'HTML Card' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'simple_neon_gauge_justgage', -'{"type":"latest","sizeX":3,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#388e3c\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":1,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":40,\"dashThickness\":1.5,\"decimals\":0,\"gaugeType\":\"donut\"},\"title\":\"Simple neon gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Simple neon gauge - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', -'radial_gauge_canvas_gauges', -'{"type":"latest","sizeX":6,"sizeY":5,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbAnalogueRadialGauge(self.ctx, ''radialGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.getSettingsSchema = function() {\n return TbAnalogueRadialGauge.settingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 50 - 25;\\nif (value < -100) {\\n\\tvalue = -100;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"maxValue\":100,\"startAngle\":45,\"ticksAngle\":270,\"showBorder\":true,\"defaultColor\":\"#e65100\",\"needleCircleSize\":10,\"highlights\":[],\"showUnitTitle\":true,\"colorPlate\":\"#fff\",\"colorMajorTicks\":\"#444\",\"colorMinorTicks\":\"#666\",\"minorTicks\":10,\"valueInt\":3,\"valueDec\":0,\"highlightsWidth\":15,\"valueBox\":true,\"animation\":true,\"animationDuration\":500,\"animationRule\":\"cycle\",\"colorNeedleShadowUp\":\"rgba(2, 255, 255, 0)\",\"numbersFont\":{\"family\":\"Roboto\",\"size\":18,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"titleFont\":{\"family\":\"Roboto\",\"size\":24,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#888\"},\"unitsFont\":{\"family\":\"Roboto\",\"size\":22,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"size\":36,\"style\":\"normal\",\"weight\":\"normal\",\"shadowColor\":\"rgba(0, 0, 0, 0.49)\",\"color\":\"#444\"},\"minValue\":-100,\"colorNeedleShadowDown\":\"rgba(188,143,143,0.45)\",\"colorValueBoxRect\":\"#888\",\"colorValueBoxRectEnd\":\"#666\",\"colorValueBoxBackground\":\"#babab2\",\"colorValueBoxShadow\":\"rgba(0,0,0,1)\"},\"title\":\"Radial gauge - Canvas Gauges\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Radial gauge - Canvas Gauges' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'digital_speedometer', -'{"type":"latest","sizeX":5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < 45) {\\n\\tvalue = 45;\\n} else if (value > 130) {\\n\\tvalue = 130;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":180,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[\"#008000\",\"#fbc02d\",\"#f44336\"],\"refreshAnimationType\":\"linear\",\"refreshAnimationTime\":700,\"startAnimationType\":\"linear\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#ffffff\"},\"neonGlowBrightness\":40,\"dashThickness\":1.5,\"decimals\":0,\"unitTitle\":\"MPH\",\"showUnitTitle\":true,\"gaugeColor\":\"#171a1c\",\"gaugeType\":\"arc\"},\"title\":\"Digital speedometer\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Digital speedometer' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'doughnut_chart_js', -'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n var borderColor = self.ctx.settings.borderColor || ''#fff'';\n var borderWidth = angular.isDefined(self.ctx.settings.borderWidth) ? self.ctx.settings.borderWidth : 5;\n \n pieData.datasets.push(dataset);\n \n for (var i=0; i < self.ctx.data.length; i++) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(borderColor);\n dataset.borderWidth.push(borderWidth);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var options = {\n responsive: false,\n maintainAspectRatio: false,\n legend: {\n display: true,\n labels: {\n fontColor: ''#666''\n }\n },\n tooltips: {\n callbacks: {\n label: function(tooltipItem, data) {\n var label = data.labels[tooltipItem.index];\n var value = data.datasets[tooltipItem.datasetIndex].data[tooltipItem.index];\n var content = label + '': '' + value;\n var units = self.ctx.settings.units ? self.ctx.settings.units : self.ctx.units;\n if (units) {\n content += '' '' + units;\n } \n return content;\n }\n }\n }\n };\n\n if (self.ctx.settings.legend) {\n options.legend.display = self.ctx.settings.legend.display !== false;\n options.legend.labels.fontColor = self.ctx.settings.legend.labelsFontColor || ''#666'';\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''doughnut'',\n data: pieData,\n options: options\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"borderWidth\": {\n \"title\": \"Border width\",\n \"type\": \"number\",\n \"default\": 5\n },\n \"borderColor\": {\n \"title\": \"Border color\",\n \"type\": \"string\",\n \"default\": \"#fff\"\n },\n \"legend\": {\n \"title\": \"Legend settings\",\n \"type\": \"object\",\n \"properties\": {\n \"display\": {\n \"title\": \"Display legend\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"labelsFontColor\": {\n \"title\": \"Labels font color\",\n \"type\": \"string\",\n \"default\": \"#666\"\n }\n }\n }\n },\n \"required\": []\n },\n \"form\": [\n \"borderWidth\", \n {\n \"key\": \"borderColor\",\n \"type\": \"color\"\n }, \n {\n \"key\": \"legend\",\n \"items\": [\n \"legend.display\",\n {\n \"key\": \"legend.labelsFontColor\",\n \"type\": \"color\"\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#26a69a\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#f57c00\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#afb42b\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#673ab7\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"borderWidth\":5,\"borderColor\":\"#fff\",\"legend\":{\"display\":true,\"labelsFontColor\":\"#666666\"}},\"title\":\"Doughnut - Chart.js\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Doughnut - Chart.js' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'pie_chart_js', -'{"type":"latest","sizeX":8,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n \n var pieData = {\n labels: [],\n datasets: []\n };\n\n var dataset = {\n data: [],\n backgroundColor: [],\n borderColor: [],\n borderWidth: [],\n hoverBackgroundColor: []\n }\n \n pieData.datasets.push(dataset);\n \n for (var i=0; i < self.ctx.data.length; i++) {\n var dataKey = self.ctx.data[i].dataKey;\n pieData.labels.push(dataKey.label);\n dataset.data.push(0);\n var hoverBackgroundColor = tinycolor(dataKey.color).lighten(15);\n var borderColor = tinycolor(dataKey.color).darken();\n dataset.backgroundColor.push(dataKey.color);\n dataset.borderColor.push(''#fff'');\n dataset.borderWidth.push(5);\n dataset.hoverBackgroundColor.push(hoverBackgroundColor.toRgbString());\n }\n\n var ctx = $(''#pieChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''pie'',\n data: pieData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n }); \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Pie - Chart.js\"}"}', -'Pie - Chart.js' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'bars', -'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var barData = {\n labels: [],\n datasets: []\n };\n \n for (var i = 0; i < self.ctx.datasources.length; i++) {\n var datasource = self.ctx.datasources[i];\n for (var d = 0; d < datasource.dataKeys.length; d++) {\n var dataset = {\n label: datasource.dataKeys[d].label,\n data: [0],\n backgroundColor: [datasource.dataKeys[d].color],\n borderColor: [datasource.dataKeys[d].color],\n borderWidth: 1\n }\n barData.datasets.push(dataset);\n }\n }\n\n var ctx = $(''#barChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''bar'',\n data: barData,\n options: {\n responsive: false,\n maintainAspectRatio: false,\n scales: {\n yAxes: [{\n ticks: {\n beginAtZero:true\n }\n }]\n }\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n var c = 0;\n for (var i = 0; i < self.ctx.chart.data.datasets.length; i++) {\n var dataset = self.ctx.chart.data.datasets[i];\n var cellData = self.ctx.data[i]; \n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n dataset.data[0] = parseFloat(value);\n }\n }\n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n self.ctx.chart.resize();\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Bars - Chart.js\"}"}', -'Bars - Chart.js' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'digital_bar', -'{"type":"latest","sizeX":6,"sizeY":2.5,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < 80) {\\n\\tvalue = 80;\\n} else if (value > 160) {\\n\\tvalue = 160;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":180,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[\"#008000\",\"#fbc02d\",\"#f44336\"],\"refreshAnimationType\":\"linear\",\"refreshAnimationTime\":700,\"startAnimationType\":\"linear\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":18},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#ffffff\"},\"neonGlowBrightness\":40,\"dashThickness\":1.5,\"decimals\":0,\"unitTitle\":\"MPH\",\"showUnitTitle\":true,\"gaugeColor\":\"#171a1c\",\"gaugeType\":\"horizontalBar\",\"showTitle\":false},\"title\":\"Digital horizontal bar\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Digital horizontal bar' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'mini_gauge_justgage', -'{"type":"latest","sizeX":2,"sizeY":2,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#7cb342\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":0,\"dashThickness\":0,\"decimals\":0,\"roundedLineCap\":true,\"gaugeType\":\"donut\"},\"title\":\"Mini gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Mini gauge - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'route_map_openstreetmap', -'{"type":"timeseries","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".leaflet-zoom-box {\n\tz-index: 9;\n}\n\n.leaflet-pane { z-index: 4; }\n\n.leaflet-tile-pane { z-index: 2; }\n.leaflet-overlay-pane { z-index: 4; }\n.leaflet-shadow-pane { z-index: 5; }\n.leaflet-marker-pane { z-index: 6; }\n.leaflet-tooltip-pane { z-index: 7; }\n.leaflet-popup-pane { z-index: 8; }\n\n.leaflet-map-pane canvas { z-index: 1; }\n.leaflet-map-pane svg { z-index: 2; }\n\n.leaflet-control {\n\tz-index: 9;\n}\n.leaflet-top,\n.leaflet-bottom {\n\tz-index: 11;\n}\n\n.tb-marker-label {\n border: none;\n background: none;\n box-shadow: none;\n}\n\n.tb-marker-label:before {\n border: none;\n background: none;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.map = new TbMapWidget(''openstreet-map'', true, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"title\": \"Route Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all markers\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"routesSettings\": {\n \"title\": \"Routes\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Route settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n },\n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"Latitude: ${lat:7}
Longitude: ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"useColorFunction\": {\n \"title\": \"Use color function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"colorFunction\": {\n \"title\": \"Color function: f(data)\",\n \"type\": \"string\"\n },\n \"markerImage\": {\n \"title\": \"Custom marker image\",\n \"type\": \"string\"\n },\n \"markerImageSize\": {\n \"title\": \"Custom marker image size (px)\",\n \"type\": \"number\",\n \"default\": 34\n },\n \"useMarkerImageFunction\": {\n \"title\": \"Use marker image function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markerImageFunction\": {\n \"title\": \"Marker image function: f(data, images)\",\n \"type\": \"string\"\n },\n \"markerImages\": {\n \"title\": \"Marker images\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker image\",\n \"type\": \"string\"\n }\n },\n \"strokeWeight\": {\n \"title\": \"Stroke weight\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"strokeOpacity\": {\n \"title\": \"Stroke opacity\",\n \"type\": \"number\",\n \"default\": 1.0\n }\n }\n }\n }\n },\n \"required\": [\n ]\n },\n \"form\": [\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"routesSettings\",\n \"items\": [\n \"routesSettings[].latKeyName\",\n \"routesSettings[].lngKeyName\",\n \"routesSettings[].showLabel\",\n \"routesSettings[].label\",\n \"routesSettings[].tooltipPattern\",\n {\n \"key\": \"routesSettings[].color\",\n \"type\": \"color\"\n },\n \"routesSettings[].useColorFunction\",\n {\n \"key\": \"routesSettings[].colorFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImage\",\n \"type\": \"image\"\n },\n \"routesSettings[].markerImageSize\",\n \"routesSettings[].useMarkerImageFunction\",\n {\n \"key\": \"routesSettings[].markerImageFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImages\",\n \"items\": [\n {\n \"key\": \"routesSettings[].markerImages[]\",\n \"type\": \"image\"\n }\n ]\n },\n \"routesSettings[].strokeWeight\",\n \"routesSettings[].strokeOpacity\"\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.8950926999078694,\"funcBody\":\"var lats = [37.7696499,\\n37.7699074,\\n37.7699536,\\n37.7697242,\\n37.7695189,\\n37.7696889,\\n37.7697153,\\n37.7701244,\\n37.7700604,\\n37.7705491,\\n37.7715705,\\n37.771752,\\n37.7707533,\\n37.769866];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lats[i];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.2757675428823283,\"funcBody\":\"var lons = [-122.4261215,\\n-122.4219157,\\n-122.4199623,\\n-122.4179074,\\n-122.4155876,\\n-122.4155521,\\n-122.4163203,\\n-122.4193876,\\n-122.4210496,\\n-122.422284,\\n-122.4232717,\\n-122.4235138,\\n-122.4247605,\\n-122.4258812];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lons[i];\"}],\"intervalSec\":60},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.14481354591724638,\"funcBody\":\"var value = prevValue;\\nif (time % 500 < 100) {\\n value = value + Math.random() * 40 - 20;\\n if (value < 45) {\\n \\tvalue = 45;\\n } else if (value > 130) {\\n \\tvalue = 130;\\n }\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":30000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"fitMapBounds\":true,\"routesSettings\":[{\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${latitude:7}
Longitude: ${longitude:7}
Speed: ${Speed} MPH
See advanced settings for details\",\"strokeWeight\":4,\"label\":\"First route\",\"color\":\"#3d5afe\",\"strokeOpacity\":1,\"useColorFunction\":true,\"useMarkerImageFunction\":true,\"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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\"],\"colorFunction\":\"var speed = data[''Speed''];\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n if (percent < 0.5) {\\n percent *=2*100; \\n return tinycolor.mix(''green'', ''yellow'', amount = percent).toHexString();\\n } else {\\n percent = (percent - 0.5)*2*100;\\n return tinycolor.mix(''yellow'', ''red'', amount = percent).toHexString();\\n }\\n}\\nreturn ''green'';\",\"markerImageFunction\":\"var speed = data[''Speed''];\\nvar res = {\\n url: images[0],\\n size: 55\\n};\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n var index = Math.floor(3 * percent);\\n res.url = images[index];\\n}\\nreturn res;\"}]},\"title\":\"Route Map - OpenStreetMap\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false}"}', -'Route Map - OpenStreetMap' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'label_widget', -'{"type":"latest","sizeX":4.5,"sizeY":5,"resources":[],"templateHtml":"","templateCss":"#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}","controllerScript":"self.onInit = function() {\n self.ctx.varsRegex = /\\$\\{([^\\}]*)\\}/g;\n \n var imageUrl = self.ctx.settings.backgroundImageUrl ? self.ctx.settings.backgroundImageUrl :\n ''data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg=='';\n\n self.ctx.$container.css(''background'', ''url(\"''+imageUrl+''\") no-repeat'');\n self.ctx.$container.css(''backgroundSize'', ''contain'');\n self.ctx.$container.css(''backgroundPosition'', ''50% 50%'');\n \n function processLabelPattern(pattern, data) {\n var match = self.ctx.varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split('':'');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith(''#'')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = self.ctx.varsRegex.exec(pattern);\n }\n return replaceInfo;\n }\n\n var configuredLabels = self.ctx.settings.labels;\n if (!configuredLabels) {\n configuredLabels = [];\n }\n \n self.ctx.labels = [];\n\n for (var l = 0; l < configuredLabels.length; l++) {\n var labelConfig = configuredLabels[l];\n var localConfig = {};\n localConfig.font = {};\n \n localConfig.pattern = labelConfig.pattern ? labelConfig.pattern : ''${#0}'';\n localConfig.x = labelConfig.x ? labelConfig.x : 0;\n localConfig.y = labelConfig.y ? labelConfig.y : 0;\n localConfig.backgroundColor = labelConfig.backgroundColor ? labelConfig.backgroundColor : ''rgba(0,0,0,0)'';\n \n var settingsFont = labelConfig.font;\n if (!settingsFont) {\n settingsFont = {};\n }\n \n localConfig.font.family = settingsFont.family || ''RobotoDraft'';\n localConfig.font.size = settingsFont.size ? settingsFont.size : 6;\n localConfig.font.style = settingsFont.style ? settingsFont.style : ''normal'';\n localConfig.font.weight = settingsFont.weight ? settingsFont.weight : ''500'';\n localConfig.font.color = settingsFont.color ? settingsFont.color : ''#fff'';\n \n localConfig.replaceInfo = processLabelPattern(localConfig.pattern, self.ctx.data);\n \n var label = {};\n var labelElement = $(''
'');\n labelElement.css(''position'', ''absolute'');\n labelElement.css(''display'', ''none'');\n labelElement.css(''top'', ''0'');\n labelElement.css(''left'', ''0'');\n labelElement.css(''backgroundColor'', localConfig.backgroundColor);\n labelElement.css(''color'', localConfig.font.color);\n labelElement.css(''fontFamily'', localConfig.font.family);\n labelElement.css(''fontStyle'', localConfig.font.style);\n labelElement.css(''fontWeight'', localConfig.font.weight);\n \n labelElement.html(localConfig.pattern);\n self.ctx.$container.append(labelElement);\n label.element = labelElement;\n label.config = localConfig;\n label.htmlSet = false;\n label.visible = false;\n self.ctx.labels.push(label);\n }\n\n var bgImg = $('''');\n bgImg.hide();\n bgImg.bind(''load'', function()\n {\n self.ctx.bImageHeight = $(this).height();\n self.ctx.bImageWidth = $(this).width();\n self.onResize();\n });\n self.ctx.$container.append(bgImg);\n bgImg.attr(''src'', imageUrl);\n \n self.onDataUpdated();\n}\n\nself.onDataUpdated = function() {\n updateLabels();\n}\n\nself.onResize = function() {\n if (self.ctx.bImageHeight && self.ctx.bImageWidth) {\n var backgroundRect = {};\n var imageRatio = self.ctx.bImageWidth / self.ctx.bImageHeight;\n var componentRatio = self.ctx.width / self.ctx.height;\n if (componentRatio >= imageRatio) {\n backgroundRect.top = 0;\n backgroundRect.bottom = 1.0;\n backgroundRect.xRatio = imageRatio / componentRatio;\n backgroundRect.yRatio = 1;\n var offset = (1 - backgroundRect.xRatio) / 2;\n backgroundRect.left = offset;\n backgroundRect.right = 1 - offset;\n } else {\n backgroundRect.left = 0;\n backgroundRect.right = 1.0;\n backgroundRect.xRatio = 1;\n backgroundRect.yRatio = componentRatio / imageRatio;\n var offset = (1 - backgroundRect.yRatio) / 2;\n backgroundRect.top = offset;\n backgroundRect.bottom = 1 - offset;\n }\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var labelLeft = backgroundRect.left*100 + (label.config.x*backgroundRect.xRatio);\n var labelTop = backgroundRect.top*100 + (label.config.y*backgroundRect.yRatio);\n var fontSize = self.ctx.height * backgroundRect.yRatio * label.config.font.size / 100;\n label.element.css(''top'', labelTop + ''%'');\n label.element.css(''left'', labelLeft + ''%'');\n label.element.css(''fontSize'', fontSize + ''px'');\n if (!label.visible) {\n label.element.css(''display'', ''block'');\n label.visible = true;\n }\n }\n } \n}\n\n\nfunction isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n}\n\nfunction padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n\n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n\n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split(''.'');\n s = int - strVal[0].length;\n\n for (; i < s; ++i) {\n strVal[0] = ''0'' + strVal[0];\n }\n\n strVal = (n ? ''-'' : '''') + strVal[0] + ''.'' + strVal[1];\n }\n\n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n\n for (; i < s; ++i) {\n strVal = ''0'' + strVal;\n }\n\n strVal = (n ? ''-'' : '''') + strVal;\n }\n\n return strVal;\n}\n\nfunction updateLabels() {\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var text = label.config.pattern;\n var replaceInfo = label.config.replaceInfo;\n var updated = false;\n for (var v = 0; v < replaceInfo.variables.length; v++) {\n var variableInfo = replaceInfo.variables[v];\n var txtVal = '''';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = self.ctx.data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n updated = true;\n } else {\n txtVal = val;\n updated = true;\n }\n }\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n if (updated || !label.htmlSet) {\n label.element.html(text);\n if (!label.htmlSet) {\n label.htmlSet = true;\n }\n }\n }\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"required\": [\"backgroundImageUrl\"],\n \"properties\": {\n \"backgroundImageUrl\": {\n \"title\": \"Background image\",\n \"type\": \"string\",\n \"default\": \"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\"\n },\n \"labels\": {\n \"title\": \"Labels\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Label\",\n \"type\": \"object\",\n \"required\": [\"pattern\"],\n \"properties\": {\n \"pattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"${#0}\"\n },\n \"x\": {\n \"title\": \"X (Percentage relative to background)\",\n \"type\": \"number\",\n \"default\": 50\n },\n \"y\": {\n \"title\": \"Y (Percentage relative to background)\",\n \"type\": \"number\",\n \"default\": 50\n },\n \"backgroundColor\": {\n \"title\": \"Backround color\",\n \"type\": \"string\",\n \"default\": \"rgba(0,0,0,0)\"\n },\n \"font\": {\n \"type\": \"object\",\n \"properties\": {\n \"family\": {\n \"title\": \"Font family\",\n \"type\": \"string\",\n \"default\": \"RobotoDraft\"\n },\n \"size\": {\n \"title\": \"Relative font size (percents)\",\n \"type\": \"number\",\n \"default\": 6\n },\n \"style\": {\n \"title\": \"Style\",\n \"type\": \"string\",\n \"default\": \"normal\"\n },\n \"weight\": {\n \"title\": \"Weight\",\n \"type\": \"string\",\n \"default\": \"500\"\n },\n \"color\": {\n \"title\": \"color\",\n \"type\": \"string\",\n \"default\": \"#fff\"\n }\n }\n }\n }\n }\n }\n }\n },\n \"form\": [\n {\n \"key\": \"backgroundImageUrl\",\n \"type\": \"image\"\n },\n {\n \"key\": \"labels\",\n \"items\": [\n \"labels[].pattern\",\n \"labels[].x\",\n \"labels[].y\",\n {\n \"key\": \"labels[].backgroundColor\",\n \"type\": \"color\"\n },\n \"labels[].font.family\",\n \"labels[].font.size\",\n {\n \"key\": \"labels[].font.style\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"italic\",\n \"label\": \"Italic\"\n },\n {\n \"value\": \"oblique\",\n \"label\": \"Oblique\"\n }\n ]\n\n },\n {\n \"key\": \"labels[].font.weight\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"normal\",\n \"label\": \"Normal\"\n },\n {\n \"value\": \"bold\",\n \"label\": \"Bold\"\n },\n {\n \"value\": \"bolder\",\n \"label\": \"Bolder\"\n },\n {\n \"value\": \"lighter\",\n \"label\": \"Lighter\"\n },\n {\n \"value\": \"100\",\n \"label\": \"100\"\n },\n {\n \"value\": \"200\",\n \"label\": \"200\"\n },\n {\n \"value\": \"300\",\n \"label\": \"300\"\n },\n {\n \"value\": \"400\",\n \"label\": \"400\"\n },\n {\n \"value\": \"500\",\n \"label\": \"500\"\n },\n {\n \"value\": \"600\",\n \"label\": \"600\"\n },\n {\n \"value\": \"700\",\n \"label\": \"800\"\n },\n {\n \"value\": \"800\",\n \"label\": \"800\"\n },\n {\n \"value\": \"900\",\n \"label\": \"900\"\n }\n ]\n },\n {\n \"key\": \"labels[].font.color\",\n \"type\": \"color\"\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"var\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"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;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"backgroundImageUrl\":\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==\",\"labels\":[{\"pattern\":\"Value: ${#0:2} units.\",\"x\":20,\"y\":47,\"font\":{\"color\":\"#515151\",\"family\":\"Roboto\",\"size\":6,\"style\":\"normal\",\"weight\":\"500\"}}]},\"title\":\"Label widget\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Label widget' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'simple_card', -'{"type":"latest","sizeX":5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n width: 100%;\n height: 100%;\n overflow: hidden;\n}\n\n.tbDatasource-table {\n width: 100%;\n height: 100%;\n border-collapse: collapse;\n white-space: nowrap;\n font-weight: 100;\n text-align: right;\n}\n\n.tbDatasource-table td {\n padding: 12px;\n position: relative;\n box-sizing: border-box;\n}\n\n.tbDatasource-data-key {\n opacity: 0.7;\n font-weight: 400;\n font-size: 3.500rem;\n}\n\n.tbDatasource-value {\n font-size: 5.000rem;\n}","controllerScript":"self.onInit = function() {\n self.ctx.units = self.ctx.settings.units || self.ctx.units;\n self.ctx.valueDec = (typeof self.ctx.settings.valueDec !== ''undefined'' && self.ctx.settings.valueDec !== null)\n ? self.ctx.settings.valueDec : self.ctx.decimals;\n \n self.ctx.labelPosition = self.ctx.settings.labelPosition || ''left'';\n \n if (self.ctx.datasources.length > 0) {\n var tbDatasource = self.ctx.datasources[0];\n var datasourceId = ''tbDatasource'' + 0;\n self.ctx.$container.append(\n \"
\"\n );\n \n self.ctx.datasourceContainer = $(''#'' + datasourceId,\n self.ctx.$container);\n \n var tableId = ''table'' + 0;\n self.ctx.datasourceContainer.append(\n \"
\"\n );\n var table = $(''#'' + tableId, self.ctx.$container);\n if (self.ctx.labelPosition === ''top'') {\n table.css(''text-align'', ''left'');\n }\n \n if (tbDatasource.dataKeys.length > 0) {\n var dataKey = tbDatasource.dataKeys[0];\n var labelCellId = ''labelCell'' + 0;\n var cellId = ''cell'' + 0;\n if (self.ctx.labelPosition === ''left'') {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n } else {\n table.append(\n \"\" +\n dataKey.label +\n \"\");\n }\n self.ctx.labelCell = $(''#'' + labelCellId, table);\n self.ctx.valueCell = $(''#'' + cellId, table);\n self.ctx.valueCell.html(0 + '' '' + self.ctx.units);\n }\n }\n \n $.fn.textWidth = function(){\n var html_org = $(this).html();\n var html_calc = '''' + html_org + '''';\n $(this).html(html_calc);\n var width = $(this).find(''span:first'').width();\n $(this).html(html_org);\n return width;\n }; \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n \n function isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n }\n \n function padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n \n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n \n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split(''.'');\n s = int - strVal[0].length;\n \n for (; i < s; ++i) {\n strVal[0] = ''0'' + strVal[0];\n }\n \n strVal = (n ? ''-'' : '''') + strVal[0] + ''.'' + strVal[1];\n }\n \n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n \n for (; i < s; ++i) {\n strVal = ''0'' + strVal;\n }\n \n strVal = (n ? ''-'' : '''') + strVal;\n }\n \n return strVal;\n }\n \n if (self.ctx.valueCell && self.ctx.data.length > 0) {\n var cellData = self.ctx.data[0];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n var txtValue;\n if (isNumber(value)) {\n txtValue = padValue(value, self.ctx.alueDec, 0) + '' '' + self.ctx.units;\n } else {\n txtValue = value;\n }\n self.ctx.valueCell.html(txtValue);\n var targetWidth;\n var minDelta;\n if (self.ctx.labelPosition === ''left'') {\n targetWidth = self.ctx.datasourceContainer.width() - self.ctx.labelCell.width();\n minDelta = self.ctx.width/16 + self.ctx.padding;\n } else {\n targetWidth = self.ctx.datasourceContainer.width();\n minDelta = self.ctx.padding;\n }\n var delta = targetWidth - self.ctx.valueCell.textWidth();\n var fontSize = self.ctx.valueFontSize;\n if (targetWidth > minDelta) {\n while (delta < minDelta && fontSize > 6) {\n fontSize--;\n self.ctx.valueCell.css(''font-size'', fontSize+''px'');\n delta = targetWidth - self.ctx.valueCell.textWidth();\n }\n }\n }\n } \n \n}\n\nself.onResize = function() {\n var labelFontSize;\n if (self.ctx.labelPosition === ''top'') {\n self.ctx.padding = self.ctx.height/20;\n labelFontSize = self.ctx.height/4;\n self.ctx.valueFontSize = self.ctx.height/2;\n } else {\n self.ctx.padding = self.ctx.width/50;\n labelFontSize = self.ctx.height/2.5;\n self.ctx.valueFontSize = height/2;\n if (self.ctx.width/self.ctx.height <= 2.7) {\n labelFontSize = self.ctx.width/7;\n self.ctx.valueFontSize = self.ctx.width/6;\n }\n }\n self.ctx.padding = Math.min(12, self.ctx.padding);\n \n if (self.ctx.labelCell) {\n self.ctx.labelCell.css(''font-size'', labelFontSize+''px'');\n self.ctx.labelCell.css(''padding'', self.ctx.padding+''px'');\n }\n if (self.ctx.valueCell) {\n self.ctx.valueCell.css(''font-size'', self.ctx.valueFontSize+''px'');\n self.ctx.valueCell.css(''padding'', self.ctx.padding+''px'');\n } \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"labelPosition\": {\n \"title\": \"Label position\",\n \"type\": \"string\",\n \"default\": \"left\"\n }\n },\n \"required\": []\n },\n \"form\": [\n {\n \"key\": \"labelPosition\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"left\",\n \"label\": \"Left\"\n },\n {\n \"value\": \"top\",\n \"label\": \"Top\"\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.2392660816082064,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ff5722\",\"color\":\"rgba(255, 255, 255, 0.87)\",\"padding\":\"16px\",\"settings\":{\"labelPosition\":\"top\"},\"title\":\"Simple card\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"units\":\"°C\",\"decimals\":0,\"useDashboardTimewindow\":true,\"showLegend\":false}"}', -'Simple card' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'lcd_bar_gauge', -'{"type":"latest","sizeX":2,"sizeY":3.5,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Humidity\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#babab2\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\"linear\",\"refreshAnimationTime\":700,\"startAnimationType\":\"linear\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"400\",\"size\":16},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":0,\"dashThickness\":1.5,\"decimals\":0,\"showUnitTitle\":true,\"defaultColor\":\"#444444\",\"gaugeType\":\"verticalBar\",\"units\":\"%\"},\"title\":\"LCD bar gauge\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'LCD bar gauge' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'vertical_bar_justgage', -'{"type":"latest","sizeX":2,"sizeY":3.5,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#f57c00\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#999999\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":12,\"color\":\"#666666\"},\"minMaxFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#666666\"},\"neonGlowBrightness\":0,\"decimals\":0,\"dashThickness\":1.5,\"gaugeColor\":\"#eeeeee\",\"showTitle\":false,\"gaugeType\":\"verticalBar\"},\"title\":\"Vertical bar - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Vertical bar - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'html_value_card', -'{"type":"latest","sizeX":7.5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.varsRegex = /\\$\\{([^\\}]*)\\}/g;\n self.ctx.htmlSet = false;\n \n var cssParser = new cssjs();\n cssParser.testMode = false;\n var namespace = ''html-value-card-'' + hashCode(self.ctx.settings.cardCss);\n cssParser.cssPreviewNamespace = namespace;\n cssParser.createStyleElement(namespace, self.ctx.settings.cardCss);\n self.ctx.$container.addClass(namespace);\n self.ctx.html = self.ctx.settings.cardHtml;\n self.ctx.replaceInfo = processHtmlPattern(self.ctx.html, self.ctx.data);\n \n updateHtml();\n \n function hashCode(str) {\n var hash = 0;\n var i, char;\n if (str.length === 0) return hash;\n for (i = 0; i < str.length; i++) {\n char = str.charCodeAt(i);\n hash = ((hash << 5) - hash) + char;\n hash = hash & hash;\n }\n return hash;\n }\n \n function processHtmlPattern(pattern, data) {\n var match = self.ctx.varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split('':'');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith(''#'')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = self.ctx.varsRegex.exec(pattern);\n }\n return replaceInfo;\n } \n}\n\nself.onDataUpdated = function() {\n updateHtml();\n}\n\nself.onDestroy = function() {\n}\n\nfunction isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n}\n\nfunction padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n\n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n\n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split(''.'');\n s = int - strVal[0].length;\n\n for (; i < s; ++i) {\n strVal[0] = ''0'' + strVal[0];\n }\n\n strVal = (n ? ''-'' : '''') + strVal[0] + ''.'' + strVal[1];\n }\n\n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n\n for (; i < s; ++i) {\n strVal = ''0'' + strVal;\n }\n\n strVal = (n ? ''-'' : '''') + strVal;\n }\n\n return strVal;\n}\n\nfunction updateHtml() {\n var text = self.ctx.html;\n var updated = false;\n for (var v in self.ctx.replaceInfo.variables) {\n var variableInfo = self.ctx.replaceInfo.variables[v];\n var txtVal = '''';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = self.ctx.data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n } else {\n txtVal = val;\n }\n }\n }\n if (typeof variableInfo.lastVal === undefined ||\n variableInfo.lastVal !== txtVal) {\n updated = true;\n variableInfo.lastVal = txtVal;\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n if (updated || !self.ctx.htmlSet) {\n self.ctx.$container.html(text);\n if (!self.ctx.htmlSet) {\n self.ctx.htmlSet = true;\n }\n }\n}\n\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"required\": [\"cardHtml\"],\n \"properties\": {\n \"cardCss\": {\n \"title\": \"CSS\",\n \"type\": \"string\",\n \"default\": \".card {\\n font-weight: bold; \\n}\"\n },\n \"cardHtml\": {\n \"title\": \"HTML\",\n \"type\": \"string\",\n \"default\": \"
HTML code here
\"\n }\n }\n },\n \"form\": [\n {\n \"key\": \"cardCss\",\n \"type\": \"css\"\n }, \n {\n \"key\": \"cardHtml\",\n \"type\": \"html\"\n } \n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"My value\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"return Math.random() * 5.45;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"cardCss\":\".card {\\n width: 100%;\\n height: 100%;\\n border: 2px solid #ccc;\\n box-sizing: border-box;\\n}\\n\\n.card .content {\\n padding: 20px;\\n display: flex;\\n flex-direction: row;\\n align-items: center;\\n justify-content: space-around;\\n height: 100%;\\n box-sizing: border-box;\\n}\\n\\n.card .content .column {\\n display: flex;\\n flex-direction: column; \\n justify-content: space-around;\\n height: 100%;\\n}\\n\\n.card h1 {\\n text-transform: uppercase;\\n color: #999;\\n font-size: 20px;\\n font-weight: bold;\\n margin: 0;\\n padding-bottom: 10px;\\n line-height: 32px;\\n}\\n\\n.card .value {\\n font-size: 38px;\\n font-weight: 200;\\n}\\n\\n.card .description {\\n font-size: 20px;\\n color: #999;\\n}\\n\",\"cardHtml\":\"
\\n
\\n
\\n

Value title

\\n
\\n ${My value:2} units.\\n
\\n
\\n Value description text\\n
\\n
\\n \\n
\\n
\"},\"title\":\"HTML Value Card\",\"dropShadow\":false}"}', -'HTML Value Card' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', -'raspberry_pi_gpio_panel', -'{"type":"latest","sizeX":7,"sizeY":10.5,"resources":[],"templateHtml":"
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n
","templateCss":".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.gpio-panel tb-led-light > div {\n margin: auto;\n}\n\n.led-panel {\n margin: 0;\n width: 66px;\n min-width: 66px;\n}\n\n.led-container {\n width: 48px;\n min-width: 48px;\n}\n\n.pin {\n margin-top: auto;\n margin-bottom: auto;\n color: white;\n font-size: 12px;\n width: 16px;\n min-width: 16px;\n}\n\n.led-panel.col-0 .pin {\n margin-left: auto;\n padding-left: 2px;\n text-align: right;\n}\n\n.led-panel.col-1 .pin {\n margin-right: auto;\n \n text-align: left;\n}\n\n.gpio-left-label {\n margin-right: 8px;\n}\n\n.gpio-right-label {\n margin-left: 8px;\n}","controllerScript":"self.onInit = function() {\n var i, gpio;\n \n var scope = self.ctx.$scope;\n var settings = self.ctx.settings;\n \n scope.gpioList = [];\n scope.gpioByPin = {};\n for (var g = 0; g < settings.gpioList.length; g++) {\n gpio = settings.gpioList[g];\n scope.gpioList.push(\n {\n row: gpio.row,\n col: gpio.col,\n pin: gpio.pin,\n label: gpio.label,\n enabled: false,\n colorOn: tinycolor(gpio.color).lighten(20).toHexString(),\n colorOff: tinycolor(gpio.color).darken().toHexString()\n }\n );\n scope.gpioByPin[gpio.pin] = scope.gpioList[scope.gpioList.length-1];\n }\n\n scope.ledPanelBackgroundColor = settings.ledPanelBackgroundColor || tinycolor(''green'').lighten(2).toRgbString();\n\n scope.gpioCells = {};\n var rowCount = 0;\n for (i = 0; i < scope.gpioList.length; i++) {\n gpio = scope.gpioList[i];\n scope.gpioCells[gpio.row+''_''+gpio.col] = gpio;\n rowCount = Math.max(rowCount, gpio.row+1);\n }\n \n scope.prefferedRowHeight = 32;\n scope.rows = [];\n for (i = 0; i < rowCount; i++) {\n var row = [];\n for (var c =0; c<2;c++) {\n if (scope.gpioCells[i+''_''+c]) {\n row[c] = scope.gpioCells[i+''_''+c];\n } else {\n row[c] = null;\n }\n }\n scope.rows.push(row);\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n var changed = false;\n for (var d = 0; d < self.ctx.data.length; d++) {\n var cellData = self.ctx.data[d];\n var dataKey = cellData.dataKey;\n var gpio = self.ctx.$scope.gpioByPin[dataKey.label];\n if (gpio) {\n var enabled = false;\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n enabled = (tvPair[1] === true || tvPair[1] === ''true'');\n }\n if (gpio.enabled != enabled) {\n changed = true;\n gpio.enabled = enabled;\n }\n }\n }\n if (changed) {\n self.ctx.$scope.$digest();\n } \n}\n\nself.onResize = function() {\n var rowCount = self.ctx.$scope.rows.length;\n var prefferedRowHeight = (self.ctx.height - 35)/rowCount;\n prefferedRowHeight = Math.min(32, prefferedRowHeight);\n prefferedRowHeight = Math.max(12, prefferedRowHeight);\n self.ctx.$scope.prefferedRowHeight = prefferedRowHeight;\n \n var ratio = prefferedRowHeight/32;\n \n var leftLabels = $(''.gpio-left-label'', self.ctx.$container);\n leftLabels.css(''font-size'', 16*ratio+''px'');\n var rightLabels = $(''.gpio-right-label'', self.ctx.$container);\n rightLabels.css(''font-size'', 16*ratio+''px'');\n var pins = $(''.pin'', self.ctx.$container);\n var pinsFontSize = Math.max(9, 12*ratio);\n pins.css(''font-size'', pinsFontSize+''px''); \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"gpioList\": {\n \"title\": \"Gpio leds\",\n \"type\": \"array\",\n \"minItems\" : 1,\n \"items\": {\n \"title\": \"Gpio led\",\n \"type\": \"object\",\n \"properties\": {\n \"pin\": {\n \"title\": \"Pin\",\n \"type\": \"number\"\n },\n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"row\": {\n \"title\": \"Row\",\n \"type\": \"number\"\n },\n \"col\": {\n \"title\": \"Column\",\n \"type\": \"number\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\",\n \"default\": \"red\"\n }\n },\n \"required\": [\"pin\", \"label\", \"row\", \"col\", \"color\"]\n }\n },\n \"ledPanelBackgroundColor\": {\n \"title\": \"LED panel background color\",\n \"type\": \"string\",\n \"default\": \"#008a00\"\n } \n },\n \"required\": [\"gpioList\", \n \"ledPanelBackgroundColor\"]\n },\n \"form\": [\n {\n \"key\": \"gpioList\",\n \"items\": [\n \"gpioList[].pin\",\n \"gpioList[].label\",\n \"gpioList[].row\",\n \"gpioList[].col\",\n {\n \"key\": \"gpioList[].color\",\n \"type\": \"color\"\n }\n ]\n },\n {\n \"key\": \"ledPanelBackgroundColor\",\n \"type\": \"color\"\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"gpioList\":[{\"pin\":1,\"label\":\"3.3V\",\"row\":0,\"col\":0,\"color\":\"#fc9700\",\"_uniqueKey\":0},{\"pin\":2,\"label\":\"5V\",\"row\":0,\"col\":1,\"color\":\"#fb0000\",\"_uniqueKey\":1},{\"pin\":3,\"label\":\"GPIO 2 (I2C1_SDA)\",\"row\":1,\"col\":0,\"color\":\"#02fefb\",\"_uniqueKey\":2},{\"color\":\"#fb0000\",\"pin\":4,\"label\":\"5V\",\"row\":1,\"col\":1},{\"color\":\"#02fefb\",\"pin\":5,\"label\":\"GPIO 3 (I2C1_SCL)\",\"row\":2,\"col\":0},{\"color\":\"#000000\",\"pin\":6,\"label\":\"GND\",\"row\":2,\"col\":1},{\"color\":\"#00fd00\",\"pin\":7,\"label\":\"GPIO 4 (GPCLK0)\",\"row\":3,\"col\":0},{\"color\":\"#fdfb00\",\"pin\":8,\"label\":\"GPIO 14 (UART_TXD)\",\"row\":3,\"col\":1},{\"color\":\"#000000\",\"pin\":9,\"label\":\"GND\",\"row\":4,\"col\":0},{\"color\":\"#fdfb00\",\"pin\":10,\"label\":\"GPIO 15 (UART_RXD)\",\"row\":4,\"col\":1},{\"color\":\"#00fd00\",\"pin\":11,\"label\":\"GPIO 17\",\"row\":5,\"col\":0},{\"color\":\"#00fd00\",\"pin\":12,\"label\":\"GPIO 18\",\"row\":5,\"col\":1},{\"color\":\"#00fd00\",\"pin\":13,\"label\":\"GPIO 27\",\"row\":6,\"col\":0},{\"color\":\"#000000\",\"pin\":14,\"label\":\"GND\",\"row\":6,\"col\":1},{\"color\":\"#00fd00\",\"pin\":15,\"label\":\"GPIO 22\",\"row\":7,\"col\":0},{\"color\":\"#00fd00\",\"pin\":16,\"label\":\"GPIO 23\",\"row\":7,\"col\":1},{\"color\":\"#fc9700\",\"pin\":17,\"label\":\"3.3V\",\"row\":8,\"col\":0},{\"color\":\"#00fd00\",\"pin\":18,\"label\":\"GPIO 24\",\"row\":8,\"col\":1},{\"color\":\"#fd01fd\",\"pin\":19,\"label\":\"GPIO 10 (SPI_MOSI)\",\"row\":9,\"col\":0},{\"color\":\"#000000\",\"pin\":20,\"label\":\"GND\",\"row\":9,\"col\":1},{\"color\":\"#fd01fd\",\"pin\":21,\"label\":\"GPIO 9 (SPI_MISO)\",\"row\":10,\"col\":0},{\"color\":\"#00fd00\",\"pin\":22,\"label\":\"GPIO 25\",\"row\":10,\"col\":1},{\"color\":\"#fd01fd\",\"pin\":23,\"label\":\"GPIO 11 (SPI_SCLK)\",\"row\":11,\"col\":0},{\"color\":\"#fd01fd\",\"pin\":24,\"label\":\"GPIO 8 (SPI_CE0)\",\"row\":11,\"col\":1},{\"color\":\"#000000\",\"pin\":25,\"label\":\"GND\",\"row\":12,\"col\":0},{\"color\":\"#fd01fd\",\"pin\":26,\"label\":\"GPIO 7 (SPI_CE1)\",\"row\":12,\"col\":1},{\"color\":\"#ffffff\",\"pin\":27,\"label\":\"ID_SD\",\"row\":13,\"col\":0},{\"color\":\"#ffffff\",\"pin\":28,\"label\":\"ID_SC\",\"row\":13,\"col\":1},{\"color\":\"#00fd00\",\"pin\":29,\"label\":\"GPIO 5\",\"row\":14,\"col\":0},{\"color\":\"#000000\",\"pin\":30,\"label\":\"GND\",\"row\":14,\"col\":1},{\"color\":\"#00fd00\",\"pin\":31,\"label\":\"GPIO 6\",\"row\":15,\"col\":0},{\"color\":\"#00fd00\",\"pin\":32,\"label\":\"GPIO 12\",\"row\":15,\"col\":1},{\"color\":\"#00fd00\",\"pin\":33,\"label\":\"GPIO 13\",\"row\":16,\"col\":0},{\"color\":\"#000000\",\"pin\":34,\"label\":\"GND\",\"row\":16,\"col\":1},{\"color\":\"#00fd00\",\"pin\":35,\"label\":\"GPIO 19\",\"row\":17,\"col\":0},{\"color\":\"#00fd00\",\"pin\":36,\"label\":\"GPIO 16\",\"row\":17,\"col\":1},{\"color\":\"#00fd00\",\"pin\":37,\"label\":\"GPIO 26\",\"row\":18,\"col\":0},{\"color\":\"#00fd00\",\"pin\":38,\"label\":\"GPIO 20\",\"row\":18,\"col\":1},{\"color\":\"#000000\",\"pin\":39,\"label\":\"GND\",\"row\":19,\"col\":0},{\"color\":\"#00fd00\",\"pin\":40,\"label\":\"GPIO 21\",\"row\":19,\"col\":1}],\"ledPanelBackgroundColor\":\"#008a00\"},\"title\":\"Raspberry Pi GPIO Panel\",\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"7\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.22518255793320163,\"funcBody\":\"var period = time % 1500;\\nreturn period < 500;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"11\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.7008206860666621,\"funcBody\":\"var period = time % 1500;\\nreturn period >= 500 && period < 1000;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"12\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.42600325102193426,\"funcBody\":\"var period = time % 1500;\\nreturn period >= 1000;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"13\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.48362241571415243,\"funcBody\":\"var period = time % 1500;\\nreturn period < 500;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"29\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.7217670147518815,\"funcBody\":\"var period = time % 1500;\\nreturn period >= 500 && period < 1000;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}}}"}', -'Raspberry Pi GPIO Panel' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'radar_chart_js', -'{"type":"latest","sizeX":7,"sizeY":5,"resources":[{"url":"https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"}],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n var barData = {\n labels: [],\n datasets: []\n };\n\n var backgroundColor = tinycolor(self.ctx.data[0].dataKey.color);\n backgroundColor.setAlpha(0.2);\n var borderColor = tinycolor(self.ctx.data[0].dataKey.color);\n borderColor.setAlpha(1);\n var dataset = {\n label: self.ctx.datasources[0].name,\n data: [],\n backgroundColor: backgroundColor.toRgbString(),\n borderColor: borderColor.toRgbString(),\n pointBackgroundColor: borderColor.toRgbString(),\n pointBorderColor: borderColor.darken().toRgbString(),\n borderWidth: 1\n }\n \n barData.datasets.push(dataset);\n \n for (var i = 0; i < self.ctx.data.length; i++) {\n var dataKey = self.ctx.data[i].dataKey;\n barData.labels.push(dataKey.label);\n dataset.data.push(0);\n }\n\n var ctx = $(''#radarChart'', self.ctx.$container);\n self.ctx.chart = new Chart(ctx, {\n type: ''radar'',\n data: barData,\n options: {\n responsive: false,\n maintainAspectRatio: false\n }\n });\n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.data.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = tvPair[1];\n self.ctx.chart.data.datasets[0].data[i] = parseFloat(value);\n }\n } \n self.ctx.chart.update();\n}\n\nself.onResize = function() {\n if (self.ctx.height >= 70) {\n self.ctx.chart.resize();\n }\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.545701115289893,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.2592906835158064,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.12880275585455747,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Radar - Chart.js\"}"}', -'Radar - Chart.js' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'neon_gauge_justgage', -'{"type":"latest","sizeX":5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":70,\"dashThickness\":1,\"decimals\":1,\"gaugeType\":\"arc\"},\"title\":\"Neon gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Neon gauge - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'simple_gauge_justgage', -'{"type":"latest","sizeX":2,"sizeY":2,"resources":[],"templateHtml":"\n","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"\nself.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#ef6c00\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32,\"color\":\"#666666\"},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":0,\"dashThickness\":0,\"decimals\":0,\"gaugeColor\":\"#eeeeee\",\"gaugeType\":\"donut\"},\"title\":\"Simple gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Simple gauge - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'route_map', -'{"type":"timeseries","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".error {\n color: red;\n}\n.tb-labels {\n color: #222;\n font: 12px/1.5 \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n text-align: center;\n width: 100px;\n white-space: nowrap;\n}","controllerScript":"self.onInit = function() {\n self.ctx.map = new TbMapWidget(''google-map'', true, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"title\": \"Route Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"gmApiKey\": {\n \"title\": \"Google Maps API Key\",\n \"type\": \"string\"\n },\n \"gmDefaultMapType\": {\n \"title\": \"Default map type\",\n \"type\": \"string\",\n \"default\": \"roadmap\"\n },\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all routes\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"routesSettings\": {\n \"title\": \"Routes\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Route settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n },\n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"Latitude: ${lat:7}
Longitude: ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"useColorFunction\": {\n \"title\": \"Use color function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"colorFunction\": {\n \"title\": \"Color function: f(data)\",\n \"type\": \"string\"\n },\n \"markerImage\": {\n \"title\": \"Custom marker image\",\n \"type\": \"string\"\n },\n \"markerImageSize\": {\n \"title\": \"Custom marker image size (px)\",\n \"type\": \"number\",\n \"default\": 34\n },\n \"useMarkerImageFunction\": {\n \"title\": \"Use marker image function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markerImageFunction\": {\n \"title\": \"Marker image function: f(data, images)\",\n \"type\": \"string\"\n },\n \"markerImages\": {\n \"title\": \"Marker images\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker image\",\n \"type\": \"string\"\n }\n },\n \"strokeWeight\": {\n \"title\": \"Stroke weight\",\n \"type\": \"number\",\n \"default\": 2\n },\n \"strokeOpacity\": {\n \"title\": \"Stroke opacity\",\n \"type\": \"number\",\n \"default\": 1.0\n }\n }\n }\n }\n },\n \"required\": [\n \"gmApiKey\"\n ]\n },\n \"form\": [\n \"gmApiKey\",\n {\n \"key\": \"gmDefaultMapType\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"roadmap\",\n \"label\": \"Roadmap\"\n },\n {\n \"value\": \"satellite\",\n \"label\": \"Satellite\"\n },\n {\n \"value\": \"hybrid\",\n \"label\": \"Hybrid\"\n },\n {\n \"value\": \"terrain\",\n \"label\": \"Terrain\"\n }\n ]\n }, \n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"routesSettings\",\n \"items\": [\n \"routesSettings[].latKeyName\",\n \"routesSettings[].lngKeyName\",\n \"routesSettings[].showLabel\",\n \"routesSettings[].label\",\n \"routesSettings[].tooltipPattern\",\n {\n \"key\": \"routesSettings[].color\",\n \"type\": \"color\"\n },\n \"routesSettings[].useColorFunction\",\n {\n \"key\": \"routesSettings[].colorFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImage\",\n \"type\": \"image\"\n },\n \"routesSettings[].markerImageSize\",\n \"routesSettings[].useMarkerImageFunction\",\n {\n \"key\": \"routesSettings[].markerImageFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"routesSettings[].markerImages\",\n \"items\": [\n {\n \"key\": \"routesSettings[].markerImages[]\",\n \"type\": \"image\"\n }\n ]\n },\n \"routesSettings[].strokeWeight\",\n \"routesSettings[].strokeOpacity\"\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.3467277073670627,\"funcBody\":\"var lats = [37.7696499,\\n37.7699074,\\n37.7699536,\\n37.7697242,\\n37.7695189,\\n37.7696889,\\n37.7697153,\\n37.7701244,\\n37.7700604,\\n37.7705491,\\n37.7715705,\\n37.771752,\\n37.7707533,\\n37.769866];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lats[i];\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"longitude\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.058309787276281666,\"funcBody\":\"var lons = [-122.4261215,\\n-122.4219157,\\n-122.4199623,\\n-122.4179074,\\n-122.4155876,\\n-122.4155521,\\n-122.4163203,\\n-122.4193876,\\n-122.4210496,\\n-122.422284,\\n-122.4232717,\\n-122.4235138,\\n-122.4247605,\\n-122.4258812];\\n\\nvar i = Math.floor((time/3 % 14000) / 1000);\\n\\nreturn lons[i];\"}],\"intervalSec\":60},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.14288960550237473,\"funcBody\":\"var value = prevValue;\\nif (time % 500 < 100) {\\n value = value + Math.random() * 40 - 20;\\n if (value < 45) {\\n \\tvalue = 45;\\n } else if (value > 130) {\\n \\tvalue = 130;\\n }\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":30000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"fitMapBounds\":true,\"routesSettings\":[{\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"color\":\"#1976d2\",\"strokeWeight\":4,\"strokeOpacity\":0.65,\"label\":\"First route\",\"tooltipPattern\":\"Latitude: ${latitude:7}
Longitude: ${longitude:7}
Speed: ${Speed} MPH
See advanced settings for details\",\"useColorFunction\":true,\"markerImageSize\":34,\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var speed = data[''Speed''];\\nvar res = {\\n url: images[0],\\n size: 55\\n};\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n var index = Math.floor(3 * percent);\\n res.url = images[index];\\n}\\nreturn res;\",\"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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\"],\"colorFunction\":\"var speed = data[''Speed''];\\nif (typeof speed !== undefined) {\\n var percent = (speed - 45)/85;\\n if (percent < 0.5) {\\n percent *=2*100; \\n return tinycolor.mix(''green'', ''yellow'', amount = percent).toHexString();\\n } else {\\n percent = (percent - 0.5)*2*100;\\n return tinycolor.mix(''yellow'', ''red'', amount = percent).toHexString();\\n }\\n}\\nreturn ''green'';\"}]},\"title\":\"Route Map\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false}"}', -'Route Map' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', -'temperature_gauge_canvas_gauges', -'{"type":"latest","sizeX":7,"sizeY":3,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbAnalogueLinearGauge(self.ctx, ''linearGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.getSettingsSchema = function() {\n return TbAnalogueLinearGauge.settingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 30 - 15;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"maxValue\":100,\"defaultColor\":\"#e64a19\",\"barStrokeWidth\":2.5,\"colorBar\":\"rgba(255, 255, 255, 0.4)\",\"colorBarEnd\":\"rgba(221, 221, 221, 0.38)\",\"showUnitTitle\":true,\"minorTicks\":2,\"valueBox\":true,\"valueInt\":3,\"colorPlate\":\"#fff\",\"colorMajorTicks\":\"#444\",\"colorMinorTicks\":\"#666\",\"colorNeedleShadowUp\":\"rgba(2,255,255,0.2)\",\"colorNeedleShadowDown\":\"rgba(188,143,143,0.45)\",\"colorValueBoxRect\":\"#888\",\"colorValueBoxRectEnd\":\"#666\",\"colorValueBoxBackground\":\"#babab2\",\"colorValueBoxShadow\":\"rgba(0,0,0,1)\",\"highlightsWidth\":10,\"animation\":true,\"animationDuration\":1500,\"animationRule\":\"linear\",\"showBorder\":false,\"majorTicksCount\":8,\"numbersFont\":{\"family\":\"Arial\",\"size\":18,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#263238\"},\"titleFont\":{\"family\":\"Roboto\",\"size\":24,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#78909c\"},\"unitsFont\":{\"family\":\"Roboto\",\"size\":26,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#37474f\"},\"valueFont\":{\"family\":\"Roboto\",\"size\":40,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#444\",\"shadowColor\":\"rgba(0,0,0,0.3)\"},\"minValue\":-60,\"highlights\":[{\"from\":-60,\"to\":-40,\"color\":\"#90caf9\"},{\"from\":-40,\"to\":-20,\"color\":\"rgba(144, 202, 249, 0.66)\"},{\"from\":-20,\"to\":0,\"color\":\"rgba(144, 202, 249, 0.33)\"},{\"from\":0,\"to\":20,\"color\":\"rgba(244, 67, 54, 0.2)\"},{\"from\":20,\"to\":40,\"color\":\"rgba(244, 67, 54, 0.4)\"},{\"from\":40,\"to\":60,\"color\":\"rgba(244, 67, 54, 0.6)\"},{\"from\":60,\"to\":80,\"color\":\"rgba(244, 67, 54, 0.8)\"},{\"from\":80,\"to\":100,\"color\":\"#f44336\"}],\"unitTitle\":\"Temperature\",\"units\":\"°C\",\"colorBarProgress\":\"#90caf9\",\"colorBarProgressEnd\":\"#f44336\",\"colorBarStroke\":\"#b0bec5\",\"valueDec\":1},\"title\":\"Temperature gauge - Canvas Gauges\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Temperature gauge - Canvas Gauges' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'google_maps', -'{"type":"latest","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".error {\n color: red;\n}\n.tb-labels {\n color: #222;\n font: 12px/1.5 \"Helvetica Neue\", Arial, Helvetica, sans-serif;\n text-align: center;\n width: 100px;\n white-space: nowrap;\n}","controllerScript":"self.onInit = function() {\n self.ctx.map = new TbMapWidget(''google-map'', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"title\": \"Google Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"gmApiKey\": {\n \"title\": \"Google Maps API Key\",\n \"type\": \"string\"\n },\n \"gmDefaultMapType\": {\n \"title\": \"Default map type\",\n \"type\": \"string\",\n \"default\": \"roadmap\"\n },\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all markers\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"markersSettings\": {\n \"title\": \"Markers\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n }, \n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"Latitude: ${lat:7}
Longitude: ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"useColorFunction\": {\n \"title\": \"Use color function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"colorFunction\": {\n \"title\": \"Color function: f(data)\",\n \"type\": \"string\"\n },\n \"markerImage\": {\n \"title\": \"Custom marker image\",\n \"type\": \"string\"\n },\n \"markerImageSize\": {\n \"title\": \"Custom marker image size (px)\",\n \"type\": \"number\",\n \"default\": 34\n },\n \"useMarkerImageFunction\": {\n \"title\": \"Use marker image function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markerImageFunction\": {\n \"title\": \"Marker image function: f(data, images)\",\n \"type\": \"string\"\n },\n \"markerImages\": {\n \"title\": \"Marker images\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker image\",\n \"type\": \"string\"\n }\n }\n }\n }\n }\n },\n \"required\": [\n \"gmApiKey\"\n ]\n },\n \"form\": [\n \"gmApiKey\",\n {\n \"key\": \"gmDefaultMapType\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"roadmap\",\n \"label\": \"Roadmap\"\n },\n {\n \"value\": \"satellite\",\n \"label\": \"Satellite\"\n },\n {\n \"value\": \"hybrid\",\n \"label\": \"Hybrid\"\n },\n {\n \"value\": \"terrain\",\n \"label\": \"Terrain\"\n }\n ]\n },\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"markersSettings\",\n \"items\": [\n \"markersSettings[].latKeyName\",\n \"markersSettings[].lngKeyName\",\n \"markersSettings[].showLabel\",\n \"markersSettings[].label\",\n \"markersSettings[].tooltipPattern\",\n {\n \"key\": \"markersSettings[].color\",\n \"type\": \"color\"\n },\n \"markersSettings[].useColorFunction\",\n {\n \"key\": \"markersSettings[].colorFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"markersSettings[].markerImage\",\n \"type\": \"image\"\n },\n \"markersSettings[].markerImageSize\",\n \"markersSettings[].useMarkerImageFunction\",\n {\n \"key\": \"markersSettings[].markerImageFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"markersSettings[].markerImages\",\n \"items\": [\n {\n \"key\": \"markersSettings[].markerImages[]\",\n \"type\": \"image\"\n }\n ]\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"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.680594833308841,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"lat\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"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\":\"lng\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.799863043034289,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"gmApiKey\":\"AIzaSyDoEx2kaGz3PxwbI9T7ccTSg5xjdw8Nw8Q\",\"markersSettings\":[{\"label\":\"First point\",\"color\":\"#1e88e5\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"useColorFunction\":true,\"colorFunction\":\"var temperature = data[''temperature''];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix(''blue'', ''red'', amount = percent).toHexString();\\n}\\nreturn ''blue'';\",\"markerImages\":[],\"useMarkerImageFunction\":false},{\"label\":\"Second point\",\"color\":\"#fdd835\",\"latKeyName\":\"lat\",\"lngKeyName\":\"lng\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${lat:7}
Longitude: ${lng:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"markerImageSize\":34,\"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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\"],\"useMarkerImageFunction\":true,\"markerImageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data[''temperature''];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.floor(4 * percent);\\n res.url = images[index];\\n}\\nreturn res;\",\"useColorFunction\":false}],\"fitMapBounds\":true,\"gmDefaultMapType\":\"roadmap\"},\"title\":\"Google Maps\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"showLegend\":false}"}', -'Google Maps' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', -'temperature_radial_gauge_canvas_gauges', -'{"type":"latest","sizeX":6,"sizeY":5,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbAnalogueRadialGauge(self.ctx, ''radialGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.getSettingsSchema = function() {\n return TbAnalogueRadialGauge.settingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temperature\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"maxValue\":60,\"startAngle\":67.5,\"ticksAngle\":225,\"showBorder\":true,\"defaultColor\":\"#e65100\",\"needleCircleSize\":7,\"highlights\":[{\"from\":-60,\"to\":-50,\"color\":\"#42a5f5\"},{\"from\":-50,\"to\":-40,\"color\":\"rgba(66, 165, 245, 0.83)\"},{\"from\":-40,\"to\":-30,\"color\":\"rgba(66, 165, 245, 0.66)\"},{\"from\":-30,\"to\":-20,\"color\":\"rgba(66, 165, 245, 0.5)\"},{\"from\":-20,\"to\":-10,\"color\":\"rgba(66, 165, 245, 0.33)\"},{\"from\":-10,\"to\":0,\"color\":\"rgba(66, 165, 245, 0.16)\"},{\"from\":0,\"to\":10,\"color\":\"rgba(229, 115, 115, 0.16)\"},{\"from\":10,\"to\":20,\"color\":\"rgba(229, 115, 115, 0.33)\"},{\"from\":20,\"to\":30,\"color\":\"rgba(229, 115, 115, 0.5)\"},{\"from\":30,\"to\":40,\"color\":\"rgba(229, 115, 115, 0.66)\"},{\"from\":40,\"to\":50,\"color\":\"rgba(229, 115, 115, 0.83)\"},{\"from\":50,\"to\":60,\"color\":\"#e57373\"}],\"showUnitTitle\":true,\"colorPlate\":\"#cfd8dc\",\"colorMajorTicks\":\"#444\",\"colorMinorTicks\":\"#666\",\"minorTicks\":2,\"valueInt\":3,\"valueDec\":1,\"highlightsWidth\":15,\"valueBox\":true,\"animation\":true,\"animationDuration\":1000,\"animationRule\":\"bounce\",\"colorNeedleShadowUp\":\"rgba(2, 255, 255, 0)\",\"colorNeedleShadowDown\":\"rgba(188, 143, 143, 0.78)\",\"units\":\"°C\",\"majorTicksCount\":12,\"numbersFont\":{\"family\":\"Roboto\",\"size\":20,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#263238\"},\"titleFont\":{\"family\":\"Roboto\",\"size\":24,\"style\":\"normal\",\"weight\":\"normal\",\"color\":\"#263238\"},\"unitsFont\":{\"family\":\"Roboto\",\"size\":28,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"size\":30,\"style\":\"normal\",\"weight\":\"normal\",\"shadowColor\":\"rgba(0, 0, 0, 0.49)\",\"color\":\"#444\"},\"colorValueBoxRect\":\"#888\",\"colorValueBoxRectEnd\":\"#666\",\"colorValueBoxBackground\":\"#babab2\",\"colorValueBoxShadow\":\"rgba(0,0,0,1)\",\"unitTitle\":\"Temperature\",\"minValue\":-60},\"title\":\"Temperature radial gauge - Canvas Gauges\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Temperature radial gauge - Canvas Gauges' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'cards', 'attributes_card', -'{"type":"latest","sizeX":7.5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}","controllerScript":"self.onInit = function() {\n \n self.ctx.datasourceTitleCells = [];\n self.ctx.valueCells = [];\n self.ctx.labelCells = [];\n \n for (var i=0; i < self.ctx.datasources.length; i++) {\n var tbDatasource = self.ctx.datasources[i];\n\n var datasourceId = ''tbDatasource'' + i;\n self.ctx.$container.append(\n \"
\"\n );\n\n var datasourceContainer = $(''#'' + datasourceId,\n self.ctx.$container);\n\n datasourceContainer.append(\n \"
\" +\n tbDatasource.name + \"
\"\n );\n \n var datasourceTitleCell = $(''.tbDatasource-title'', datasourceContainer);\n self.ctx.datasourceTitleCells.push(datasourceTitleCell);\n \n var tableId = ''table'' + i;\n datasourceContainer.append(\n \"
\"\n );\n var table = $(''#'' + tableId, self.ctx.$container);\n\n for (var a = 0; a < tbDatasource.dataKeys.length; a++) {\n var dataKey = tbDatasource.dataKeys[a];\n var labelCellId = ''labelCell'' + a;\n var cellId = ''cell'' + a;\n table.append(\"\" + dataKey.label +\n \"\");\n var labelCell = $(''#'' + labelCellId, table);\n self.ctx.labelCells.push(labelCell);\n var valueCell = $(''#'' + cellId, table);\n self.ctx.valueCells.push(valueCell);\n }\n } \n \n self.onResize();\n}\n\nself.onDataUpdated = function() {\n for (var i = 0; i < self.ctx.valueCells.length; i++) {\n var cellData = self.ctx.data[i];\n if (cellData && cellData.data && cellData.data.length > 0) {\n var tvPair = cellData.data[cellData.data.length -\n 1];\n var value = tvPair[1];\n self.ctx.valueCells[i].html(value);\n }\n } \n}\n\nself.onResize = function() {\n var datasoirceTitleFontSize = self.ctx.height/8;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n datasoirceTitleFontSize = self.ctx.width/12;\n }\n datasoirceTitleFontSize = Math.min(datasoirceTitleFontSize, 20);\n for (var i = 0; i < self.ctx.datasourceTitleCells.length; i++) {\n self.ctx.datasourceTitleCells[i].css(''font-size'', datasoirceTitleFontSize+''px'');\n }\n var valueFontSize = self.ctx.height/9;\n var labelFontSize = self.ctx.height/9;\n if (self.ctx.width/self.ctx.height <= 1.5) {\n valueFontSize = self.ctx.width/15;\n labelFontSize = self.ctx.width/15;\n }\n valueFontSize = Math.min(valueFontSize, 18);\n labelFontSize = Math.min(labelFontSize, 18);\n\n for (i = 0; i < self.ctx.valueCells; i++) {\n self.ctx.valueCells[i].css(''font-size'', valueFontSize+''px'');\n self.ctx.valueCells[i].css(''height'', valueFontSize*2.5+''px'');\n self.ctx.valueCells[i].css(''padding'', ''0px '' + valueFontSize + ''px'');\n self.ctx.labelCells[i].css(''font-size'', labelFontSize+''px'');\n self.ctx.labelCells[i].css(''height'', labelFontSize*2.5+''px'');\n self.ctx.labelCells[i].css(''padding'', ''0px '' + labelFontSize + ''px'');\n } \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"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;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Attributes card\"}"}', -'Attributes card' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'digital_thermometer', -'{"type":"latest","sizeX":3,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < -60) {\\n\\tvalue = 60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#000000\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":60,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":1,\"levelColors\":[\"#304ffe\",\"#7e57c2\",\"#ff4081\",\"#d32f2f\"],\"refreshAnimationType\":\"<>\",\"refreshAnimationTime\":700,\"startAnimationType\":\"<>\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":18},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"dashThickness\":1.5,\"decimals\":0,\"minValue\":-60,\"units\":\"°C\",\"gaugeColor\":\"#333333\",\"neonGlowBrightness\":35,\"gaugeType\":\"donut\"},\"title\":\"Digital thermometer\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Digital thermometer' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', -'raspberry_pi_gpio_control', -'{"type":"rpc","sizeX":6,"sizeY":10.5,"resources":[],"templateHtml":"
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n {{rpcErrorText}}\n \n
","templateCss":".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.switch-panel {\n margin: 0;\n height: 32px;\n width: 66px;\n min-width: 66px;\n}\n\n.switch-panel md-switch {\n margin: 0;\n width: 36px;\n min-width: 36px;\n}\n\n.switch-panel md-switch > div.md-container {\n margin: 0;\n}\n\n.switch-panel.col-0 md-switch {\n margin-left: 8px;\n margin-right: 4px;\n}\n\n.switch-panel.col-1 md-switch {\n margin-left: 4px;\n margin-right: 8px;\n}\n\n.gpio-row {\n height: 32px;\n}\n\n.pin {\n margin-top: auto;\n margin-bottom: auto;\n color: white;\n font-size: 12px;\n width: 16px;\n min-width: 16px;\n}\n\n.switch-panel.col-0 .pin {\n margin-left: auto;\n padding-left: 2px;\n text-align: right;\n}\n\n.switch-panel.col-1 .pin {\n margin-right: auto;\n \n text-align: left;\n}\n\n.gpio-left-label {\n margin-right: 8px;\n}\n\n.gpio-right-label {\n margin-left: 8px;\n}","controllerScript":"self.onInit = function() {\n \n var i, gpio;\n var scope = self.ctx.$scope;\n var settings = self.ctx.settings;\n scope.gpioList = [];\n for (var g = 0; g < settings.gpioList.length; g++) {\n gpio = settings.gpioList[g];\n scope.gpioList.push(\n {\n row: gpio.row,\n col: gpio.col,\n pin: gpio.pin,\n label: gpio.label,\n enabled: false\n }\n );\n }\n\n scope.requestTimeout = settings.requestTimeout || 1000;\n\n scope.switchPanelBackgroundColor = settings.switchPanelBackgroundColor || tinycolor(''green'').lighten(2).toRgbString();\n\n scope.gpioStatusRequest = {\n method: \"getGpioStatus\",\n paramsBody: \"{}\"\n };\n \n if (settings.gpioStatusRequest) {\n scope.gpioStatusRequest.method = settings.gpioStatusRequest.method || scope.gpioStatusRequest.method;\n scope.gpioStatusRequest.paramsBody = settings.gpioStatusRequest.paramsBody || scope.gpioStatusRequest.paramsBody;\n }\n \n scope.gpioStatusChangeRequest = {\n method: \"setGpioStatus\",\n paramsBody: \"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"\n };\n \n if (settings.gpioStatusChangeRequest) {\n scope.gpioStatusChangeRequest.method = settings.gpioStatusChangeRequest.method || scope.gpioStatusChangeRequest.method;\n scope.gpioStatusChangeRequest.paramsBody = settings.gpioStatusChangeRequest.paramsBody || scope.gpioStatusChangeRequest.paramsBody;\n }\n \n scope.parseGpioStatusFunction = \"return body[pin] === true;\";\n \n if (settings.parseGpioStatusFunction && settings.parseGpioStatusFunction.length > 0) {\n scope.parseGpioStatusFunction = settings.parseGpioStatusFunction;\n }\n \n scope.parseGpioStatusFunction = new Function(\"body, pin\", scope.parseGpioStatusFunction);\n \n function requestGpioStatus() {\n self.ctx.controlApi.sendTwoWayCommand(scope.gpioStatusRequest.method, \n scope.gpioStatusRequest.paramsBody, \n scope.requestTimeout)\n .then(\n function success(responseBody) {\n for (var g = 0; g < scope.gpioList.length; g++) {\n var gpio = scope.gpioList[g];\n var enabled = scope.parseGpioStatusFunction.apply(this, [responseBody, gpio.pin]);\n gpio.enabled = enabled; \n }\n }\n );\n }\n \n function changeGpioStatus(gpio) {\n var pin = gpio.pin + '''';\n var enabled = !gpio.enabled;\n enabled = enabled === true ? ''true'' : ''false'';\n var paramsBody = scope.gpioStatusChangeRequest.paramsBody;\n var requestBody = JSON.parse(paramsBody.replace(\"\\\"{$pin}\\\"\", pin).replace(\"\\\"{$enabled}\\\"\", enabled));\n self.ctx.controlApi.sendTwoWayCommand(scope.gpioStatusChangeRequest.method, \n requestBody, scope.requestTimeout)\n .then(\n function success(responseBody) {\n var enabled = scope.parseGpioStatusFunction.apply(this, [responseBody, gpio.pin]);\n gpio.enabled = enabled;\n }\n );\n }\n \n scope.gpioCells = {};\n var rowCount = 0;\n for (i = 0; i < scope.gpioList.length; i++) {\n gpio = scope.gpioList[i];\n scope.gpioCells[gpio.row+''_''+gpio.col] = gpio;\n rowCount = Math.max(rowCount, gpio.row+1);\n }\n \n scope.prefferedRowHeight = 32;\n scope.rows = [];\n for (i = 0; i < rowCount; i++) {\n var row = [];\n for (var c =0; c<2;c++) {\n if (scope.gpioCells[i+''_''+c]) {\n row[c] = scope.gpioCells[i+''_''+c];\n } else {\n row[c] = null;\n }\n }\n scope.rows.push(row);\n }\n\n scope.gpioClick = function($event, gpio) {\n changeGpioStatus(gpio);\n };\n\n requestGpioStatus(); \n \n self.onResize();\n}\n\nself.onResize = function() {\n var scope = self.ctx.$scope;\n var rowCount = scope.rows.length;\n var prefferedRowHeight = (self.ctx.height - 35)/rowCount;\n prefferedRowHeight = Math.min(32, prefferedRowHeight);\n prefferedRowHeight = Math.max(12, prefferedRowHeight);\n scope.prefferedRowHeight = prefferedRowHeight;\n var ratio = prefferedRowHeight/32;\n var switches = $(''md-switch'', self.ctx.$container);\n switches.css(''height'', 30*ratio+''px'');\n switches.css(''width'', 36*ratio+''px'');\n switches.css(''min-width'', 36*ratio+''px'');\n $(''.md-container'', switches).css(''height'', 24*ratio+''px'');\n $(''.md-container'', switches).css(''width'', 36*ratio+''px'');\n var bars = $(''.md-bar'', self.ctx.$container);\n bars.css(''height'', 14*ratio+''px'');\n bars.css(''width'', 34*ratio+''px'');\n var thumbs = $(''.md-thumb'', self.ctx.$container);\n thumbs.css(''height'', 20*ratio+''px'');\n thumbs.css(''width'', 20*ratio+''px'');\n \n var leftLabels = $(''.gpio-left-label'', self.ctx.$container);\n leftLabels.css(''font-size'', 16*ratio+''px'');\n var rightLabels = $(''.gpio-right-label'', self.ctx.$container);\n rightLabels.css(''font-size'', 16*ratio+''px'');\n var pins = $(''.pin'', self.ctx.$container);\n var pinsFontSize = Math.max(9, 12*ratio);\n pins.css(''font-size'', pinsFontSize+''px''); \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"gpioList\": {\n \"title\": \"Gpio switches\",\n \"type\": \"array\",\n \"minItems\" : 1,\n \"items\": {\n \"title\": \"Gpio switch\",\n \"type\": \"object\",\n \"properties\": {\n \"pin\": {\n \"title\": \"Pin\",\n \"type\": \"number\"\n },\n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"row\": {\n \"title\": \"Row\",\n \"type\": \"number\"\n },\n \"col\": {\n \"title\": \"Column\",\n \"type\": \"number\"\n }\n },\n \"required\": [\"pin\", \"label\", \"row\", \"col\"]\n }\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout\",\n \"type\": \"number\",\n \"default\": 500\n },\n \"switchPanelBackgroundColor\": {\n \"title\": \"Switches panel background color\",\n \"type\": \"string\",\n \"default\": \"#008a00\"\n },\n \"gpioStatusRequest\": {\n \"title\": \"GPIO status request\",\n \"type\": \"object\",\n \"properties\": {\n \"method\": {\n \"title\": \"Method name\",\n \"type\": \"string\",\n \"default\": \"getGpioStatus\"\n },\n \"paramsBody\": {\n \"title\": \"Method body\",\n \"type\": \"string\",\n \"default\": \"{}\"\n }\n },\n \"required\": [\"method\", \"paramsBody\"]\n },\n \"gpioStatusChangeRequest\": {\n \"title\": \"GPIO status change request\",\n \"type\": \"object\",\n \"properties\": {\n \"method\": {\n \"title\": \"Method name\",\n \"type\": \"string\",\n \"default\": \"setGpioStatus\"\n },\n \"paramsBody\": {\n \"title\": \"Method body\",\n \"type\": \"string\",\n \"default\": \"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"\n }\n },\n \"required\": [\"method\", \"paramsBody\"]\n },\n \"parseGpioStatusFunction\": {\n \"title\": \"Parse gpio status function\",\n \"type\": \"string\",\n \"default\": \"return body[pin] === true;\"\n } \n },\n \"required\": [\"gpioList\", \n \"requestTimeout\",\n \"switchPanelBackgroundColor\",\n \"gpioStatusRequest\",\n \"gpioStatusChangeRequest\",\n \"parseGpioStatusFunction\"]\n },\n \"form\": [\n \"gpioList\",\n \"requestTimeout\",\n {\n \"key\": \"switchPanelBackgroundColor\",\n \"type\": \"color\"\n },\n {\n \"key\": \"gpioStatusRequest\",\n \"items\": [\n \"gpioStatusRequest.method\",\n {\n \"key\": \"gpioStatusRequest.paramsBody\",\n \"type\": \"json\"\n }\n ]\n },\n {\n \"key\": \"gpioStatusChangeRequest\",\n \"items\": [\n \"gpioStatusChangeRequest.method\",\n {\n \"key\": \"gpioStatusChangeRequest.paramsBody\",\n \"type\": \"json\"\n }\n ]\n },\n {\n \"key\": \"parseGpioStatusFunction\",\n \"type\": \"javascript\"\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"targetDeviceAliases\":[],\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"parseGpioStatusFunction\":\"return body[pin] === true;\",\"gpioStatusChangeRequest\":{\"method\":\"setGpioStatus\",\"paramsBody\":\"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"},\"requestTimeout\":500,\"switchPanelBackgroundColor\":\"#008a00\",\"gpioStatusRequest\":{\"method\":\"getGpioStatus\",\"paramsBody\":\"{}\"},\"gpioList\":[{\"pin\":7,\"label\":\"GPIO 4 (GPCLK0)\",\"row\":3,\"col\":0,\"_uniqueKey\":0},{\"pin\":11,\"label\":\"GPIO 17\",\"row\":5,\"col\":0,\"_uniqueKey\":1},{\"pin\":12,\"label\":\"GPIO 18\",\"row\":5,\"col\":1,\"_uniqueKey\":2},{\"_uniqueKey\":3,\"pin\":13,\"label\":\"GPIO 27\",\"row\":6,\"col\":0},{\"_uniqueKey\":4,\"pin\":15,\"label\":\"GPIO 22\",\"row\":7,\"col\":0},{\"_uniqueKey\":5,\"pin\":16,\"label\":\"GPIO 23\",\"row\":7,\"col\":1},{\"_uniqueKey\":6,\"pin\":18,\"label\":\"GPIO 24\",\"row\":8,\"col\":1},{\"_uniqueKey\":7,\"pin\":22,\"label\":\"GPIO 25\",\"row\":10,\"col\":1},{\"_uniqueKey\":8,\"pin\":29,\"label\":\"GPIO 5\",\"row\":14,\"col\":0},{\"_uniqueKey\":9,\"pin\":31,\"label\":\"GPIO 6\",\"row\":15,\"col\":0},{\"_uniqueKey\":10,\"pin\":32,\"label\":\"GPIO 12\",\"row\":15,\"col\":1},{\"_uniqueKey\":11,\"pin\":33,\"label\":\"GPIO 13\",\"row\":16,\"col\":0},{\"_uniqueKey\":12,\"pin\":35,\"label\":\"GPIO 19\",\"row\":17,\"col\":0},{\"_uniqueKey\":13,\"pin\":36,\"label\":\"GPIO 16\",\"row\":17,\"col\":1},{\"_uniqueKey\":14,\"pin\":37,\"label\":\"GPIO 26\",\"row\":18,\"col\":0},{\"_uniqueKey\":15,\"pin\":38,\"label\":\"GPIO 20\",\"row\":18,\"col\":1},{\"_uniqueKey\":16,\"pin\":40,\"label\":\"GPIO 21\",\"row\":19,\"col\":1}]},\"title\":\"Raspberry Pi GPIO Control\"}"}', -'Raspberry Pi GPIO Control' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'gpio_widgets', -'basic_gpio_control', -'{"type":"rpc","sizeX":4,"sizeY":2,"resources":[],"templateHtml":"
\n
\n
\n
\n {{ cell.label }}\n
\n {{cell.pin}}\n \n \n \n \n {{cell.pin}}\n
\n {{ cell.label }}\n
\n
\n \n \n \n
\n
\n
\n {{rpcErrorText}}\n \n
","templateCss":".error {\n font-size: 14px !important;\n color: maroon;/*rgb(250,250,250);*/\n background-color: transparent;\n padding: 6px;\n}\n\n.error span {\n margin: auto;\n}\n\n.gpio-panel {\n padding-top: 10px;\n white-space: nowrap;\n}\n\n.switch-panel {\n margin: 0;\n height: 32px;\n width: 66px;\n min-width: 66px;\n}\n\n.switch-panel md-switch {\n margin: 0;\n width: 36px;\n min-width: 36px;\n}\n\n.switch-panel md-switch > div.md-container {\n margin: 0;\n}\n\n.switch-panel.col-0 md-switch {\n padding-left: 8px;\n padding-right: 4px;\n}\n\n.switch-panel.col-1 md-switch {\n padding-left: 4px;\n padding-right: 8px;\n}\n\n.gpio-row {\n height: 32px;\n}\n\n.pin {\n margin-top: auto;\n margin-bottom: auto;\n color: white;\n font-size: 12px;\n width: 16px;\n min-width: 16px;\n}\n\n.switch-panel.col-0 .pin {\n margin-left: auto;\n padding-left: 2px;\n text-align: right;\n}\n\n.switch-panel.col-1 .pin {\n margin-right: auto;\n \n text-align: left;\n}\n\n.gpio-left-label {\n margin-right: 8px;\n}\n\n.gpio-right-label {\n margin-left: 8px;\n}","controllerScript":"self.onInit = function() {\n \n var i, gpio;\n var scope = self.ctx.$scope;\n var settings = self.ctx.settings;\n scope.gpioList = [];\n for (var g = 0; g < settings.gpioList.length; g++) {\n gpio = settings.gpioList[g];\n scope.gpioList.push(\n {\n row: gpio.row,\n col: gpio.col,\n pin: gpio.pin,\n label: gpio.label,\n enabled: false\n }\n );\n }\n\n scope.requestTimeout = settings.requestTimeout || 1000;\n\n scope.switchPanelBackgroundColor = settings.switchPanelBackgroundColor || tinycolor(''green'').lighten(2).toRgbString();\n\n scope.gpioStatusRequest = {\n method: \"getGpioStatus\",\n paramsBody: \"{}\"\n };\n \n if (settings.gpioStatusRequest) {\n scope.gpioStatusRequest.method = settings.gpioStatusRequest.method || scope.gpioStatusRequest.method;\n scope.gpioStatusRequest.paramsBody = settings.gpioStatusRequest.paramsBody || scope.gpioStatusRequest.paramsBody;\n }\n \n scope.gpioStatusChangeRequest = {\n method: \"setGpioStatus\",\n paramsBody: \"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"\n };\n \n if (settings.gpioStatusChangeRequest) {\n scope.gpioStatusChangeRequest.method = settings.gpioStatusChangeRequest.method || scope.gpioStatusChangeRequest.method;\n scope.gpioStatusChangeRequest.paramsBody = settings.gpioStatusChangeRequest.paramsBody || scope.gpioStatusChangeRequest.paramsBody;\n }\n \n scope.parseGpioStatusFunction = \"return body[pin] === true;\";\n \n if (settings.parseGpioStatusFunction && settings.parseGpioStatusFunction.length > 0) {\n scope.parseGpioStatusFunction = settings.parseGpioStatusFunction;\n }\n \n scope.parseGpioStatusFunction = new Function(\"body, pin\", scope.parseGpioStatusFunction);\n \n function requestGpioStatus() {\n self.ctx.controlApi.sendTwoWayCommand(scope.gpioStatusRequest.method, \n scope.gpioStatusRequest.paramsBody, \n scope.requestTimeout)\n .then(\n function success(responseBody) {\n for (var g = 0; g < scope.gpioList.length; g++) {\n var gpio = scope.gpioList[g];\n var enabled = scope.parseGpioStatusFunction.apply(this, [responseBody, gpio.pin]);\n gpio.enabled = enabled; \n }\n }\n );\n }\n \n function changeGpioStatus(gpio) {\n var pin = gpio.pin + '''';\n var enabled = !gpio.enabled;\n enabled = enabled === true ? ''true'' : ''false'';\n var paramsBody = scope.gpioStatusChangeRequest.paramsBody;\n var requestBody = JSON.parse(paramsBody.replace(\"\\\"{$pin}\\\"\", pin).replace(\"\\\"{$enabled}\\\"\", enabled));\n self.ctx.controlApi.sendTwoWayCommand(scope.gpioStatusChangeRequest.method, \n requestBody, scope.requestTimeout)\n .then(\n function success(responseBody) {\n var enabled = scope.parseGpioStatusFunction.apply(this, [responseBody, gpio.pin]);\n gpio.enabled = enabled;\n }\n );\n }\n \n scope.gpioCells = {};\n var rowCount = 0;\n for (i = 0; i < scope.gpioList.length; i++) {\n gpio = scope.gpioList[i];\n scope.gpioCells[gpio.row+''_''+gpio.col] = gpio;\n rowCount = Math.max(rowCount, gpio.row+1);\n }\n \n scope.prefferedRowHeight = 32;\n scope.rows = [];\n for (i = 0; i < rowCount; i++) {\n var row = [];\n for (var c =0; c<2;c++) {\n if (scope.gpioCells[i+''_''+c]) {\n row[c] = scope.gpioCells[i+''_''+c];\n } else {\n row[c] = null;\n }\n }\n scope.rows.push(row);\n }\n\n scope.gpioClick = function($event, gpio) {\n changeGpioStatus(gpio);\n };\n\n requestGpioStatus(); \n \n self.onResize();\n}\n\nself.onResize = function() {\n var scope = self.ctx.$scope;\n var rowCount = scope.rows.length;\n var prefferedRowHeight = (self.ctx.height - 35)/rowCount;\n prefferedRowHeight = Math.min(32, prefferedRowHeight);\n prefferedRowHeight = Math.max(12, prefferedRowHeight);\n scope.prefferedRowHeight = prefferedRowHeight;\n var ratio = prefferedRowHeight/32;\n var switches = $(''md-switch'', self.ctx.$container);\n switches.css(''height'', 30*ratio+''px'');\n switches.css(''width'', 36*ratio+''px'');\n switches.css(''min-width'', 36*ratio+''px'');\n $(''.md-container'', switches).css(''height'', 24*ratio+''px'');\n $(''.md-container'', switches).css(''width'', 36*ratio+''px'');\n var bars = $(''.md-bar'', self.ctx.$container);\n bars.css(''height'', 14*ratio+''px'');\n bars.css(''width'', 34*ratio+''px'');\n var thumbs = $(''.md-thumb'', self.ctx.$container);\n thumbs.css(''height'', 20*ratio+''px'');\n thumbs.css(''width'', 20*ratio+''px'');\n \n var leftLabels = $(''.gpio-left-label'', self.ctx.$container);\n leftLabels.css(''font-size'', 16*ratio+''px'');\n var rightLabels = $(''.gpio-right-label'', self.ctx.$container);\n rightLabels.css(''font-size'', 16*ratio+''px'');\n var pins = $(''.pin'', self.ctx.$container);\n var pinsFontSize = Math.max(9, 12*ratio);\n pins.css(''font-size'', pinsFontSize+''px''); \n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"Settings\",\n \"properties\": {\n \"gpioList\": {\n \"title\": \"Gpio switches\",\n \"type\": \"array\",\n \"minItems\" : 1,\n \"items\": {\n \"title\": \"Gpio switch\",\n \"type\": \"object\",\n \"properties\": {\n \"pin\": {\n \"title\": \"Pin\",\n \"type\": \"number\"\n },\n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"row\": {\n \"title\": \"Row\",\n \"type\": \"number\"\n },\n \"col\": {\n \"title\": \"Column\",\n \"type\": \"number\"\n }\n },\n \"required\": [\"pin\", \"label\", \"row\", \"col\"]\n }\n },\n \"requestTimeout\": {\n \"title\": \"RPC request timeout\",\n \"type\": \"number\",\n \"default\": 500\n },\n \"switchPanelBackgroundColor\": {\n \"title\": \"Switches panel background color\",\n \"type\": \"string\",\n \"default\": \"#008a00\"\n },\n \"gpioStatusRequest\": {\n \"title\": \"GPIO status request\",\n \"type\": \"object\",\n \"properties\": {\n \"method\": {\n \"title\": \"Method name\",\n \"type\": \"string\",\n \"default\": \"getGpioStatus\"\n },\n \"paramsBody\": {\n \"title\": \"Method body\",\n \"type\": \"string\",\n \"default\": \"{}\"\n }\n },\n \"required\": [\"method\", \"paramsBody\"]\n },\n \"gpioStatusChangeRequest\": {\n \"title\": \"GPIO status change request\",\n \"type\": \"object\",\n \"properties\": {\n \"method\": {\n \"title\": \"Method name\",\n \"type\": \"string\",\n \"default\": \"setGpioStatus\"\n },\n \"paramsBody\": {\n \"title\": \"Method body\",\n \"type\": \"string\",\n \"default\": \"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"\n }\n },\n \"required\": [\"method\", \"paramsBody\"]\n },\n \"parseGpioStatusFunction\": {\n \"title\": \"Parse gpio status function\",\n \"type\": \"string\",\n \"default\": \"return body[pin] === true;\"\n } \n },\n \"required\": [\"gpioList\", \n \"requestTimeout\",\n \"switchPanelBackgroundColor\",\n \"gpioStatusRequest\",\n \"gpioStatusChangeRequest\",\n \"parseGpioStatusFunction\"]\n },\n \"form\": [\n \"gpioList\",\n \"requestTimeout\",\n {\n \"key\": \"switchPanelBackgroundColor\",\n \"type\": \"color\"\n },\n {\n \"key\": \"gpioStatusRequest\",\n \"items\": [\n \"gpioStatusRequest.method\",\n {\n \"key\": \"gpioStatusRequest.paramsBody\",\n \"type\": \"json\"\n }\n ]\n },\n {\n \"key\": \"gpioStatusChangeRequest\",\n \"items\": [\n \"gpioStatusChangeRequest.method\",\n {\n \"key\": \"gpioStatusChangeRequest.paramsBody\",\n \"type\": \"json\"\n }\n ]\n },\n {\n \"key\": \"parseGpioStatusFunction\",\n \"type\": \"javascript\"\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"targetDeviceAliases\":[],\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"parseGpioStatusFunction\":\"return body[pin] === true;\",\"gpioStatusChangeRequest\":{\"method\":\"setGpioStatus\",\"paramsBody\":\"{\\n \\\"pin\\\": \\\"{$pin}\\\",\\n \\\"enabled\\\": \\\"{$enabled}\\\"\\n}\"},\"requestTimeout\":500,\"switchPanelBackgroundColor\":\"#b71c1c\",\"gpioStatusRequest\":{\"method\":\"getGpioStatus\",\"paramsBody\":\"{}\"},\"gpioList\":[{\"pin\":1,\"label\":\"GPIO 1\",\"row\":0,\"col\":0,\"_uniqueKey\":0},{\"pin\":2,\"label\":\"GPIO 2\",\"row\":0,\"col\":1,\"_uniqueKey\":1},{\"pin\":3,\"label\":\"GPIO 3\",\"row\":1,\"col\":0,\"_uniqueKey\":2}]},\"title\":\"Basic GPIO Control\"}"}', -'Basic GPIO Control' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'horizontal_bar_justgage', -'{"type":"latest","sizeX":7,"sizeY":3,"resources":[],"templateHtml":"\n","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#999999\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":18,\"color\":\"#666666\"},\"minMaxFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#666666\"},\"neonGlowBrightness\":0,\"decimals\":0,\"dashThickness\":0,\"gaugeColor\":\"#eeeeee\",\"showTitle\":true,\"gaugeType\":\"horizontalBar\"},\"title\":\"Horizontal bar - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Horizontal bar - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', -'gauge_justgage', -'{"type":"latest","sizeX":4,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Temp\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#ffffff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":100,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\">\",\"refreshAnimationTime\":700,\"startAnimationType\":\">\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#999999\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Roboto\",\"style\":\"normal\",\"weight\":\"500\",\"size\":36,\"color\":\"#666666\"},\"minMaxFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#666666\"},\"neonGlowBrightness\":0,\"decimals\":0,\"dashThickness\":0,\"gaugeColor\":\"#eeeeee\",\"showTitle\":true,\"gaugeType\":\"arc\"},\"title\":\"Gauge - justGage\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Gauge - justGage' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'digital_gauges', 'lcd_gauge', -'{"type":"latest","sizeX":5,"sizeY":3,"resources":[],"templateHtml":"","templateCss":"#gauge {\n text-align: center;\n /* margin-left: -100px;\n margin-right: -100px;*/\n /*margin-top: -50px;*/\n \n}\n","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbCanvasDigitalGauge(self.ctx, ''digitalGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.getSettingsSchema = function() {\n return TbCanvasDigitalGauge.settingsSchema;\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 180) {\\n\\tvalue = 180;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"#babab2\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0px\",\"settings\":{\"maxValue\":180,\"minValue\":0,\"donutStartAngle\":90,\"showValue\":true,\"showMinMax\":true,\"gaugeWidthScale\":0.75,\"levelColors\":[],\"refreshAnimationType\":\"linear\",\"refreshAnimationTime\":700,\"startAnimationType\":\"linear\",\"startAnimationTime\":700,\"titleFont\":{\"family\":\"Roboto\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"labelFont\":{\"family\":\"Roboto\",\"size\":8,\"style\":\"normal\",\"weight\":\"500\"},\"valueFont\":{\"family\":\"Segment7Standard\",\"style\":\"normal\",\"weight\":\"500\",\"size\":32},\"minMaxFont\":{\"family\":\"Segment7Standard\",\"size\":12,\"style\":\"normal\",\"weight\":\"500\"},\"neonGlowBrightness\":0,\"dashThickness\":1.5,\"decimals\":0,\"unitTitle\":\"MPH\",\"showUnitTitle\":true,\"defaultColor\":\"#444444\",\"gaugeType\":\"arc\"},\"title\":\"LCD gauge\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'LCD gauge' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'maps', 'openstreetmap', -'{"type":"latest","sizeX":8.5,"sizeY":6,"resources":[],"templateHtml":"","templateCss":".leaflet-zoom-box {\n\tz-index: 9;\n}\n\n.leaflet-pane { z-index: 4; }\n\n.leaflet-tile-pane { z-index: 2; }\n.leaflet-overlay-pane { z-index: 4; }\n.leaflet-shadow-pane { z-index: 5; }\n.leaflet-marker-pane { z-index: 6; }\n.leaflet-tooltip-pane { z-index: 7; }\n.leaflet-popup-pane { z-index: 8; }\n\n.leaflet-map-pane canvas { z-index: 1; }\n.leaflet-map-pane svg { z-index: 2; }\n\n.leaflet-control {\n\tz-index: 9;\n}\n.leaflet-top,\n.leaflet-bottom {\n\tz-index: 11;\n}\n\n.tb-marker-label {\n border: none;\n background: none;\n box-shadow: none;\n}\n\n.tb-marker-label:before {\n border: none;\n background: none;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.map = new TbMapWidget(''openstreet-map'', false, self.ctx);\n}\n\nself.onDataUpdated = function() {\n self.ctx.map.update();\n}\n\nself.onResize = function() {\n self.ctx.map.resize();\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{\n \"schema\": {\n \"title\": \"Google Map Configuration\",\n \"type\": \"object\",\n \"properties\": {\n \"defaultZoomLevel\": {\n \"title\": \"Default map zoom level (1 - 20)\",\n \"type\": \"number\"\n },\n \"fitMapBounds\": {\n \"title\": \"Fit map bounds to cover all markers\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"markersSettings\": {\n \"title\": \"Markers\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker settings\",\n \"type\": \"object\",\n \"properties\": {\n \"latKeyName\": {\n \"title\": \"Latitude key name\",\n \"type\": \"string\",\n \"default\": \"lat\"\n },\n \"lngKeyName\": {\n \"title\": \"Longitude key name\",\n \"type\": \"string\",\n \"default\": \"lng\"\n }, \n \"showLabel\": {\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n }, \n \"label\": {\n \"title\": \"Label\",\n \"type\": \"string\"\n },\n \"tooltipPattern\": {\n \"title\": \"Pattern ( for ex. ''Text ${keyName} units.'' or ''${#} units'' )\",\n \"type\": \"string\",\n \"default\": \"Latitude: ${lat:7}
Longitude: ${lng:7}\"\n },\n \"color\": {\n \"title\": \"Color\",\n \"type\": \"string\"\n },\n \"useColorFunction\": {\n \"title\": \"Use color function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"colorFunction\": {\n \"title\": \"Color function: f(data)\",\n \"type\": \"string\"\n },\n \"markerImage\": {\n \"title\": \"Custom marker image\",\n \"type\": \"string\"\n },\n \"markerImageSize\": {\n \"title\": \"Custom marker image size (px)\",\n \"type\": \"number\",\n \"default\": 34\n },\n \"useMarkerImageFunction\": {\n \"title\": \"Use marker image function\",\n \"type\": \"boolean\",\n \"default\": false\n },\n \"markerImageFunction\": {\n \"title\": \"Marker image function: f(data, images)\",\n \"type\": \"string\"\n },\n \"markerImages\": {\n \"title\": \"Marker images\",\n \"type\": \"array\",\n \"items\": {\n \"title\": \"Marker image\",\n \"type\": \"string\"\n }\n }\n }\n }\n }\n },\n \"required\": [\n ]\n },\n \"form\": [\n \"defaultZoomLevel\",\n \"fitMapBounds\",\n {\n \"key\": \"markersSettings\",\n \"items\": [\n \"markersSettings[].latKeyName\",\n \"markersSettings[].lngKeyName\",\n \"markersSettings[].showLabel\",\n \"markersSettings[].label\",\n \"markersSettings[].tooltipPattern\",\n {\n \"key\": \"markersSettings[].color\",\n \"type\": \"color\"\n },\n \"markersSettings[].useColorFunction\",\n {\n \"key\": \"markersSettings[].colorFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"markersSettings[].markerImage\",\n \"type\": \"image\"\n },\n \"markersSettings[].markerImageSize\",\n \"markersSettings[].useMarkerImageFunction\",\n {\n \"key\": \"markersSettings[].markerImageFunction\",\n \"type\": \"javascript\"\n },\n {\n \"key\": \"markersSettings[].markerImages\",\n \"items\": [\n {\n \"key\": \"markersSettings[].markerImages[]\",\n \"type\": \"image\"\n }\n ]\n }\n ]\n }\n ]\n}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"latitude\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.05427416942713381,\"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.680594833308841,\"funcBody\":\"var value = prevValue || -90.454350;\\nif (time % 5000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"lat\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.05012157428742059,\"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\":\"lng\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.6742359401617628,\"funcBody\":\"var value = prevValue || -84.845334;\\nif (time % 4000 < 500) {\\n value += Math.random() * 0.05 - 0.025;\\n}\\nreturn value;\"}]},{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"temperature\",\"color\":\"#607d8b\",\"settings\":{},\"_hash\":0.21553274887887564,\"funcBody\":\"var value = prevValue + Math.random() * 40 - 20;\\nif (value < -60) {\\n\\tvalue = -60;\\n} else if (value > 60) {\\n\\tvalue = 60;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"markersSettings\":[{\"label\":\"First point\",\"color\":\"#1e88e5\",\"latKeyName\":\"latitude\",\"lngKeyName\":\"longitude\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${latitude:7}
Longitude: ${longitude:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"useColorFunction\":true,\"colorFunction\":\"var temperature = data[''temperature''];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120 * 100;\\n return tinycolor.mix(''blue'', ''red'', amount = percent).toHexString();\\n}\\nreturn ''blue'';\"},{\"label\":\"Second point\",\"color\":\"#fdd835\",\"latKeyName\":\"lat\",\"lngKeyName\":\"lng\",\"showLabel\":true,\"tooltipPattern\":\"Latitude: ${lat:7}
Longitude: ${lng:7}
Temperature: ${temperature} °C
See advanced settings for details\",\"markerImageSize\":34,\"useMarkerImageFunction\":true,\"markerImages\":[\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnNjkzNCIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjEwLjg3bW0iIHdpZHRoPSI0OS45NjZtbSIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDE3Ny4wNDM3NSA3NDcuMTYyNDkiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiA8ZGVmcyBpZD0iZGVmczY5MzYiPgogIDxsaW5lYXJHcmFkaWVudCBpZD0ibGluZWFyR3JhZGllbnQ0NzA3IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgwIC03MTcuNzcgLTcxNy43NyAwIDcwOC4xOCAyMTY4KSI+CiAgIDxzdG9wIGlkPSJzdG9wNDcwOSIgc3RvcC1jb2xvcj0iIzg0ZDdmNSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNDcxMSIgc3RvcC1jb2xvcj0iIzZhOTljZCIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NDY4OSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoNDU2LjIyIDAgMCAtNDU2LjIyIDQ4MC4wNyAzNDk0LjMpIj4KICAgPHN0b3AgaWQ9InN0b3A0NjkxIiBzdG9wLWNvbG9yPSIjY2FjOWM4IiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A0NjkzIiBzdG9wLWNvbG9yPSIjZjZmNmY2IiBvZmZzZXQ9Ii43NTI2OSIvPgogICA8c3RvcCBpZD0ic3RvcDQ2OTUiIHN0b3AtY29sb3I9IiNkNGQzZDIiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDQ2NjkiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEzNDUuNSAwIDAgLTEzNDUuNSAzNS40MjcgMzA2NS45KSI+CiAgIDxzdG9wIGlkPSJzdG9wNDY3MSIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNDY3MyIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIuMSIvPgogICA8c3RvcCBpZD0ic3RvcDQ2NzUiIHN0b3AtY29sb3I9IiNmZWZmZmYiIG9mZnNldD0iLjI0NzMxIi8+CiAgIDxzdG9wIGlkPSJzdG9wNDY3NyIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NDU5MSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMCAtNzE3Ljc3IC03MTcuNzcgMCA3MDguMTggMjE2OCkiPgogICA8c3RvcCBpZD0ic3RvcDQ1OTMiIHN0b3AtY29sb3I9IiM1ZmM3ZjMiIG9mZnNldD0iMCIvPgogICA8c3RvcCBpZD0ic3RvcDQ1OTUiIHN0b3AtY29sb3I9IiMyNzUyYTYiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDQ1NzMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDQ1Ni4yMiAwIDAgLTQ1Ni4yMiA0ODAuMDcgMzQ5NC4zKSI+CiAgIDxzdG9wIGlkPSJzdG9wNDU3NSIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNDU3NyIgc3RvcC1jb2xvcj0iI2VjZWNlYyIgb2Zmc2V0PSIuNzUyNjkiLz4KICAgPHN0b3AgaWQ9InN0b3A0NTc5IiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg2MDk3LTMiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MDk5LTEiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0ibTM4Mi4zMiAxOTAuMDNjMCA0OC4yMzkgMTQ1Ljg5IDg3LjM5MSAzMjUuODkgODcuMzkxIDE3OS45MSAwIDMyNS44LTM5LjE1MiAzMjUuOC04Ny4zOTEgMC00OC4zMzItMTQ1Ljg5LTg3LjQ4LTMyNS44LTg3LjQ4LTE4MCAwLTMyNS44OSAzOS4xNDgtMzI1Ljg5IDg3LjQ4Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNDcwMy00IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNDcwNS05IiBkPSJtNTg1LjI5IDE1MzEuN2MtMzAuNDY1IDAtNTUuMTYgMjQuNjktNTUuMTYgNTUuMTZ2NTI4LjUyYzI4LjQzNyAzMS4xOCA2Ni45OTIgNTMuMDEgMTEwLjMyIDYwLjE0di01ODguNjZjMC0zMC40Ny0yNC42OTUtNTUuMTYtNTUuMTYtNTUuMTYiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0Njg1LTYiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0Njg3LTgiIGQ9Im01MzAuMTMgMjExNS40djM1NzQuMmMwIDMwLjQ2IDI0LjY5NSA1NS4xNiA1NS4xNiA1NS4xNnM1NS4xNi0yNC43IDU1LjE2LTU1LjE2di0zNTE0Yy00My4zMjgtNy4xMy04MS44ODMtMjguOTYtMTEwLjMyLTYwLjE0Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNDY2NS02IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNDY2Ny00IiBkPSJtNzQ0LjkgNTk0MS45aC03My40NDJjLTE1NC4zNyAwLTI3OS45Ni0xMjUuNTktMjc5Ljk2LTI3OS45NnYtNDIwNS41Yy0yMTguMTEtMTE2LjItMzU2LjA3LTM0My4yLTM1Ni4wNy01OTMuNjUtMC4wMDQtMzcwLjk2IDMwMS43OS02NzIuNzUgNjcyLjc1LTY3Mi43NSAzNzAuOTUgMCA2NzIuNzQgMzAxLjc5IDY3Mi43NCA2NzIuNzUgMCAyNTAuNDgtMTM3Ljk2IDQ3Ny40OC0zNTYuMDcgNTkzLjd2NDIwNS41YzAgMTU0LjM3LTEyNS41OSAyNzkuOTYtMjc5Ljk2IDI3OS45Nm0wLTg4LjU4YzEwNS4yNiAwIDE5MS4zOS04Ni4xMiAxOTEuMzktMTkxLjM4di00MjYxLjJjMjA5LjI5LTg4Ljg1IDM1Ni4wNy0yOTYuMjUgMzU2LjA3LTUzNy45NCAwLTMyMi42My0yNjEuNTQtNTg0LjE4LTU4NC4xNy01ODQuMThzLTU4NC4xOCAyNjEuNTUtNTg0LjE4IDU4NC4xOGMwIDI0MS42OSAxNDYuNzkgNDQ5LjA5IDM1Ni4wNyA1MzcuOTR2NDI2MS4yYzAgMTA1LjI2IDg2LjEyNSAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NDIiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0NjUxLTgiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0NjUzLTciIGQ9Im03MDguMTggOTU1Ljg5Yy0xMzUuNDggMC0yNTIuNTkgNDkuODY5LTMwOC4xNCAxMjIuMjQtMjIuMDA4IDI4LjY3LTM0LjM1OSA2MC44OC0zNC4zNTkgOTQuOTEgMCAxMTkuOTIgMTUzLjM0IDIxNy4xNCAzNDIuNSAyMTcuMTQgMTY0LjQgMCAzMDEuNzQtNzMuNDQgMzM0Ljg4LTE3MS4zOSA0Ljk4LTE0Ljc1IDcuNjEtMzAuMDYgNy42MS00NS43NSAwLTUzLjkyLTMwLjk5LTEwMy4yNC04Mi4yOTUtMTQxLjIxLTYyLjgxMi00Ni40NzgtMTU2LjA5LTc1LjkzOS0yNjAuMi03NS45MzkiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0NjM1LTEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0NjM3LTciIGQ9Im05NjguMzggMTAzMS44YzUxLjMwNSAzNy45NyA4Mi4yOTUgODcuMjkgODIuMjk1IDE0MS4yMXYtMC43MmMtMC4yOC01My42My0zMS4yMS0xMDIuNjktODIuMjk1LTE0MC40OW0tNTY4LjM0IDQ2LjNjLTIyLjAwOCAyOC42Ny0zNC4zNTkgNjAuODgtMzQuMzU5IDk0LjkxIDAtMzQuMDMgMTIuMzUxLTY2LjI0IDM0LjM1OS05NC45MW02NTAuNjMgOTQuOTFjMCAxNS42OS0yLjYzIDMxLTcuNjEgNDUuNzUgNC45MS0xNC41MyA3LjUzLTI5LjYgNy42MS00NS4wNHYtMC43MSIvPgogIDwvY2xpcFBhdGg+CiAgPHJhZGlhbEdyYWRpZW50IGlkPSJyYWRpYWxHcmFkaWVudDQ2MzktMiIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGN5PSIwIiBjeD0iMCIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCg1ODQuMTggMCAwIC01ODQuMTggNzA4LjE4IDg2Mi43NSkiIHI9IjEiPgogICA8c3RvcCBpZD0ic3RvcDQ2NDEtNyIgc3RvcC1jb2xvcj0iIzVmYzdmMyIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNDY0My0yIiBzdG9wLWNvbG9yPSIjMjc1MmE2IiBvZmZzZXQ9IjEiLz4KICA8L3JhZGlhbEdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0NjE5LTIiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0NjIxLTYiIGQ9Im0xMDUwLjcgMTE3Mi4zdjAuNzIgMC43MS0wLjcxLTAuNzIiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0NjAzLTEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0NjA1LTUiIGQ9Im0xMjkyLjQgODYyLjc1YzAtMzIyLjY0LTI2MS41NC01ODQuMTgtNTg0LjE3LTU4NC4xOHMtNTg0LjE4IDI2MS41NC01ODQuMTggNTg0LjE4YzAgMzIyLjYzIDI2MS41NSA1ODQuMTggNTg0LjE4IDU4NC4xOHM1ODQuMTctMjYxLjU1IDU4NC4xNy01ODQuMTgiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg0NTg3LTAiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg0NTg5LTkiIGQ9Im00ODAuMDcgMTQwMC43djU4NS45NWMwIDQ5LjQ1IDE5IDk0LjY3IDUwLjA2NyAxMjguNzJ2LTUyOC41MmMwLTMwLjQ3IDI0LjY5NS01NS4xNiA1NS4xNi01NS4xNnM1NS4xNiAyNC42OSA1NS4xNiA1NS4xNnY1ODguNjZjMTAuMDk4IDEuNjcgMjAuNDUzIDIuNTMgMzEuMDA0IDIuNTNoNzMuNDM4YzEwNS4yNiAwIDE5MS4zOS04Ni4xMiAxOTEuMzktMTkxLjM5di01ODUuOTNjLTcwLjA4MiAyOS43Ni0xNDcuMTcgNDYuMjItMjI4LjEgNDYuMjItODAuOTM0IDAtMTU4LjAzLTE2LjQ2LTIyOC4xMS00Ni4yMiIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDQ1NjktNyIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDQ1NzEtMSIgZD0ibTkzNi4yOCAxOTg2LjZjMCAxMDUuMjctODYuMTIxIDE5MS4zOS0xOTEuMzkgMTkxLjM5aC03My40MzhjLTEwLjU1MSAwLTIwLjkwNi0wLjg2LTMxLjAwNC0yLjUzdjM1MTRjMCAzMC40Ni0yNC42OTUgNTUuMTYtNTUuMTYgNTUuMTZzLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTM1NzQuMmMtMzEuMDY3LTM0LjA1LTUwLjA2Ny03OS4yNy01MC4wNjctMTI4LjcydjM2NzUuM2MwIDEzLjQ2IDEuNDExIDI2LjYyIDQuMDkgMzkuMzEgMTguMjYyIDg2LjU3IDk1LjUwNCAxNTIuMDcgMTg3LjMgMTUyLjA3aDczLjQzOGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtMzY3NS4zIi8+CiAgPC9jbGlwUGF0aD4KIDwvZGVmcz4KIDxnIGlkPSJsYXllcjEiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0zNjIuNzYgLTE4OC44NCkiPgogIDxnIGZpbGw9IiNmZmYiPgogICA8cGF0aCBpZD0icGF0aDQxODMiIGQ9Im00NTEuMjggOTM2Yy00OC44ODkgMC04OC41MjItMTAuNjMyLTg4LjUyMi0yMy43NSAwLTcuMDg2NCAxMS41NzEtMTMuNDQ5IDI5LjkxOC0xNy44IDAuMDM1NiAwLjAyOTggMC4wNjc0IDAuMDU4NSAwLjEwMjUgMC4wODk3LTE4LjIyIDQuMzM1LTI5LjcgMTAuNjYzLTI5LjcgMTcuNzEgMCAxMy4wNjkgMzkuNDg5IDIzLjY2NCA4OC4yMDEgMjMuNjY0czg4LjItMTAuNTk1IDg4LjItMjMuNjY0YzAtNy4wNDc0LTExLjQ4LTEzLjM3NS0yOS43LTE3LjcxIDAuMDM1LTAuMDMxMiAwLjA2NzUtMC4wNiAwLjEwMjUtMC4wODk3IDE4LjM0OCA0LjM1MSAyOS45MTkgMTAuNzEzIDI5LjkxOSAxNy44IDAgMTMuMTE4LTM5LjYzMiAyMy43NS04OC41MjIgMjMuNzUiLz4KICAgPHBhdGggaWQ9InBhdGg0MTg1IiBkPSJtNDUxLjI4IDkzNS45MmMtNDguNzEyIDAtODguMjAxLTEwLjU5NS04OC4yMDEtMjMuNjY0IDAtNy4wNDc0IDExLjQ4LTEzLjM3NSAyOS43LTE3LjcxIDAuMDMzMyAwLjAyODkgMC4wNjk5IDAuMDYxMSAwLjEwMyAwLjA5MTQtMTguMDg5IDQuMzE3NC0yOS40ODIgMTAuNjExLTI5LjQ4MiAxNy42MTkgMCAxMy4wMjEgMzkuMzQ1IDIzLjU3OCA4Ny44OCAyMy41NzggNDguNTM2IDAgODcuODgtMTAuNTU3IDg3Ljg4LTIzLjU3OCAwLTcuMDA3NC0xMS4zOTQtMTMuMzAxLTI5LjQ4Mi0xNy42MTkgMC4wMzI1LTAuMDMwMiAwLjA3LTAuMDYyNSAwLjEwMjUtMC4wOTE0IDE4LjIyIDQuMzM1IDI5LjcgMTAuNjYzIDI5LjcgMTcuNzEgMCAxMy4wNjktMzkuNDg5IDIzLjY2NC04OC4yIDIzLjY2NCIvPgogICA8cGF0aCBpZD0icGF0aDQxODciIGQ9Im00NTEuMjggOTM1LjgzYy00OC41MzUgMC04Ny44OC0xMC41NTctODcuODgtMjMuNTc4IDAtNy4wMDc0IDExLjM5My0xMy4zMDEgMjkuNDgyLTE3LjYxOSAwLjAzNTEgMC4wMjk3IDAuMDY3OSAwLjA1ODYgMC4xMDMgMC4wODk5LTE3Ljk1OSA0LjMwMjYtMjkuMjY1IDEwLjU2Mi0yOS4yNjUgMTcuNTI5IDAgMTIuOTc0IDM5LjIwMiAyMy40OTEgODcuNTYgMjMuNDkxczg3LjU1OS0xMC41MTggODcuNTU5LTIzLjQ5MWMwLTYuOTY3Mi0xMS4zMDUtMTMuMjI2LTI5LjI2NS0xNy41MjkgMC4wMzYyLTAuMDMxMiAwLjA2NzUtMC4wNjAxIDAuMTAzNzUtMC4wODk5IDE4LjA4OSA0LjMxNzQgMjkuNDgyIDEwLjYxMSAyOS40ODIgMTcuNjE5IDAgMTMuMDIxLTM5LjM0NSAyMy41NzgtODcuODggMjMuNTc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDE4OSIgZD0ibTQ1MS4yOCA5MzUuNzRjLTQ4LjM1OCAwLTg3LjU2LTEwLjUxOC04Ny41Ni0yMy40OTEgMC02Ljk2NzIgMTEuMzA2LTEzLjIyNiAyOS4yNjUtMTcuNTI5IDAuMDMzNyAwLjAyODggMC4wNzA0IDAuMDYxIDAuMTAzNjIgMC4wODk3LTE3LjgzIDQuMjg2Ny0yOS4wNDcgMTAuNTExLTI5LjA0NyAxNy40MzkgMCAxMi45MjYgMzkuMDU4IDIzLjQwNiA4Ny4yMzkgMjMuNDA2IDQ4LjE4IDAgODcuMjM5LTEwLjQ4IDg3LjIzOS0yMy40MDYgMC02LjkyNzgtMTEuMjE5LTEzLjE1Mi0yOS4wNDktMTcuNDM5IDAuMDMzNy0wLjAyODcgMC4wNzEyLTAuMDYxIDAuMTAzNzUtMC4wODk3IDE3Ljk2IDQuMzAyNiAyOS4yNjUgMTAuNTYyIDI5LjI2NSAxNy41MjkgMCAxMi45NzQtMzkuMjAxIDIzLjQ5MS04Ny41NTkgMjMuNDkxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDE5MSIgZD0ibTQ1MS4yOCA5MzUuNjZjLTQ4LjE4MSAwLTg3LjIzOS0xMC40OC04Ny4yMzktMjMuNDA2IDAtNi45Mjc4IDExLjIxOC0xMy4xNTIgMjkuMDQ3LTE3LjQzOSAwLjAzNTYgMC4wMzEyIDAuMDY4MyAwLjA2MDEgMC4xMDQgMC4wOTE0LTE3LjcwMSA0LjI2OS0yOC44MzEgMTAuNDU5LTI4LjgzMSAxNy4zNDggMCAxMi44NzkgMzguOTE0IDIzLjMyIDg2LjkxOCAyMy4zMiA0OC4wMDMgMCA4Ni45MTgtMTAuNDQxIDg2LjkxOC0yMy4zMiAwLTYuODg4Ni0xMS4xMy0xMy4wNzktMjguODMxLTE3LjM0OCAwLjAzNjMtMC4wMzEyIDAuMDY4OC0wLjA2MDEgMC4xMDM3NS0wLjA5MTQgMTcuODMgNC4yODY2IDI5LjA0OSAxMC41MTEgMjkuMDQ5IDE3LjQzOSAwIDEyLjkyNi0zOS4wNTkgMjMuNDA2LTg3LjIzOSAyMy40MDYiLz4KICAgPHBhdGggaWQ9InBhdGg0MTkzIiBkPSJtNDUxLjI4IDkzNS41N2MtNDguMDA0IDAtODYuOTE4LTEwLjQ0MS04Ni45MTgtMjMuMzIgMC02Ljg4ODYgMTEuMTI5LTEzLjA3OSAyOC44MzEtMTcuMzQ4IDAuMDM1NiAwLjAzMTIgMC4wNjg4IDAuMDYgMC4xMDQgMC4wOTAyLTE3LjU2OSA0LjI1MjYtMjguNjE0IDEwLjQxLTI4LjYxNCAxNy4yNTcgMCAxMi44MzEgMzguNzcxIDIzLjIzNCA4Ni41OTcgMjMuMjM0IDQ3LjgyNyAwIDg2LjU5Ny0xMC40MDMgODYuNTk3LTIzLjIzNCAwLTYuODQ3Ni0xMS4wNDUtMTMuMDA1LTI4LjYxNC0xNy4yNTcgMC4wMzUtMC4wMzAzIDAuMDY4Ny0wLjA1OSAwLjEwMzc1LTAuMDkwMiAxNy43MDEgNC4yNjkgMjguODMxIDEwLjQ1OSAyOC44MzEgMTcuMzQ4IDAgMTIuODc5LTM4LjkxNSAyMy4zMi04Ni45MTggMjMuMzIiLz4KICAgPHBhdGggaWQ9InBhdGg0MTk1IiBkPSJtNDUxLjI4IDkzNS40OWMtNDcuODI3IDAtODYuNTk3LTEwLjQwMy04Ni41OTctMjMuMjM0IDAtNi44NDc2IDExLjA0NS0xMy4wMDUgMjguNjE0LTE3LjI1NyAwLjAzMzYgMC4wMjk5IDAuMDcxMiAwLjA2MjUgMC4xMDQ1IDAuMDkxNC0xNy40NDEgNC4yMzQ5LTI4LjM5OCAxMC4zNTctMjguMzk4IDE3LjE2NiAwIDEyLjc4NCAzOC42MjcgMjMuMTQ3IDg2LjI3NyAyMy4xNDcgNDcuNjQ5IDAgODYuMjc3LTEwLjM2NCA4Ni4yNzctMjMuMTQ3IDAtNi44MDg2LTEwLjk1OC0xMi45MzEtMjguMzk5LTE3LjE2NiAwLjAzMzctMC4wMjg5IDAuMDcxMi0wLjA2MTUgMC4xMDUtMC4wOTE0IDE3LjU2OSA0LjI1MjUgMjguNjE0IDEwLjQxIDI4LjYxNCAxNy4yNTcgMCAxMi44MzEtMzguNzcgMjMuMjM0LTg2LjU5NyAyMy4yMzQiLz4KICAgPHBhdGggaWQ9InBhdGg0MTk3IiBkPSJtNDUxLjI4IDkzNS40Yy00Ny42NSAwLTg2LjI3Ny0xMC4zNjQtODYuMjc3LTIzLjE0NyAwLTYuODA4NiAxMC45NTctMTIuOTMxIDI4LjM5OC0xNy4xNjYgMC4wMzU2IDAuMDMwOCAwLjA2OTMgMC4wNTk1IDAuMTA0ODggMC4wOTA4LTE3LjMxIDQuMjE4Ny0yOC4xODIgMTAuMzA3LTI4LjE4MiAxNy4wNzUgMCAxMi43MzYgMzguNDg0IDIzLjA2MSA4NS45NTYgMjMuMDYxczg1Ljk1Ni0xMC4zMjUgODUuOTU2LTIzLjA2MWMwLTYuNzY4NS0xMC44NzEtMTIuODU2LTI4LjE4MS0xNy4wNzUgMC4wMzUtMC4wMzEyIDAuMDY4OC0wLjA2IDAuMTAzNzUtMC4wOTA4IDE3LjQ0MSA0LjIzNDkgMjguMzk5IDEwLjM1NyAyOC4zOTkgMTcuMTY2IDAgMTIuNzg0LTM4LjYyOCAyMy4xNDctODYuMjc3IDIzLjE0NyIvPgogICA8cGF0aCBpZD0icGF0aDQxOTkiIGQ9Im00NTEuMjggOTM1LjMxYy00Ny40NzIgMC04NS45NTYtMTAuMzI1LTg1Ljk1Ni0yMy4wNjEgMC02Ljc2ODUgMTAuODcyLTEyLjg1NiAyOC4xODItMTcuMDc1IDAuMDM1NyAwLjAzMDQgMC4wNjk5IDAuMDYwMSAwLjEwNTUgMC4wOTA0LTE3LjE4NCA0LjIwMjEtMjcuOTY3IDEwLjI1Ni0yNy45NjcgMTYuOTg1IDAgMTIuNjg4IDM4LjM0IDIyLjk3NiA4NS42MzUgMjIuOTc2IDQ3LjI5NCAwIDg1LjYzNi0xMC4yODggODUuNjM2LTIyLjk3NiAwLTYuNzI5LTEwLjc4NC0xMi43ODMtMjcuOTY4LTE2Ljk4NSAwLjAzNjItMC4wMzAzIDAuMDctMC4wNiAwLjEwNjI1LTAuMDkwNCAxNy4zMSA0LjIxODggMjguMTgxIDEwLjMwNyAyOC4xODEgMTcuMDc1IDAgMTIuNzM2LTM4LjQ4NCAyMy4wNjEtODUuOTU2IDIzLjA2MSIvPgogICA8cGF0aCBpZD0icGF0aDQyMDEiIGQ9Im00NTEuMjggOTM1LjIzYy00Ny4yOTUgMC04NS42MzUtMTAuMjg4LTg1LjYzNS0yMi45NzYgMC02LjcyOSAxMC43ODMtMTIuNzgzIDI3Ljk2Ny0xNi45ODUgMC4wMzM4IDAuMDI4OSAwLjA3MTggMC4wNjI1IDAuMTA1NSAwLjA5MTQtMTcuMDU0IDQuMTg0NS0yNy43NTEgMTAuMjA0LTI3Ljc1MSAxNi44OTQgMCAxMi42NDEgMzguMTk2IDIyLjg5IDg1LjMxNCAyMi44OXM4NS4zMTQtMTAuMjQ5IDg1LjMxNC0yMi44OWMwLTYuNjg5LTEwLjY5OC0xMi43MDktMjcuNzUxLTE2Ljg5NCAwLjAzMzctMC4wMjg5IDAuMDcxMi0wLjA2MjUgMC4xMDUtMC4wOTE0IDE3LjE4NCA0LjIwMjEgMjcuOTY4IDEwLjI1NiAyNy45NjggMTYuOTg1IDAgMTIuNjg4LTM4LjM0MSAyMi45NzYtODUuNjM2IDIyLjk3NiIvPgogICA8cGF0aCBpZD0icGF0aDQyMDMiIGQ9Im00NTEuMjggOTM1LjE0Yy00Ny4xMTggMC04NS4zMTQtMTAuMjQ5LTg1LjMxNC0yMi44OSAwLTYuNjg5IDEwLjY5Ny0xMi43MDkgMjcuNzUxLTE2Ljg5NCAwLjAzNTYgMC4wMjk4IDAuMDY5NyAwLjA1OTUgMC4xMDYgMC4wODk3LTE2LjkyNSA0LjE2OS0yNy41MzcgMTAuMTU0LTI3LjUzNyAxNi44MDQgMCAxMi41OTQgMzguMDUzIDIyLjgwNCA4NC45OTQgMjIuODA0IDQ2Ljk0IDAgODQuOTkzLTEwLjIxIDg0Ljk5My0yMi44MDQgMC02LjY0OTktMTAuNjExLTEyLjYzNS0yNy41MzYtMTYuODA0IDAuMDM2Mi0wLjAzMDMgMC4wNy0wLjA2IDAuMTA2MjUtMC4wODk3IDE3LjA1NCA0LjE4NDUgMjcuNzUxIDEwLjIwNCAyNy43NTEgMTYuODk0IDAgMTIuNjQxLTM4LjE5NiAyMi44OS04NS4zMTQgMjIuODkiLz4KICAgPHBhdGggaWQ9InBhdGg0MjA1IiBkPSJtNDUxLjI4IDkzNS4wNmMtNDYuOTQgMC04NC45OTQtMTAuMjEtODQuOTk0LTIyLjgwNCAwLTYuNjQ5OSAxMC42MTEtMTIuNjM1IDI3LjUzNy0xNi44MDQgMC4wMzU2IDAuMDMxMiAwLjA3MDMgMC4wNjAxIDAuMTA1ODcgMC4wOTE0LTE2Ljc5NiA0LjE1MTQtMjcuMzIyIDEwLjEwMi0yNy4zMjIgMTYuNzEyIDAgMTIuNTQ2IDM3LjkwOSAyMi43MTcgODQuNjczIDIyLjcxNyA0Ni43NjMgMCA4NC42NzMtMTAuMTcxIDg0LjY3My0yMi43MTcgMC02LjYwOTktMTAuNTI2LTEyLjU2MS0yNy4zMjItMTYuNzEyIDAuMDM2Mi0wLjAzMTIgMC4wNzEyLTAuMDYwMSAwLjEwNjI1LTAuMDkxNCAxNi45MjUgNC4xNjkgMjcuNTM2IDEwLjE1NCAyNy41MzYgMTYuODA0IDAgMTIuNTk0LTM4LjA1MiAyMi44MDQtODQuOTkzIDIyLjgwNCIvPgogICA8cGF0aCBpZD0icGF0aDQyMDciIGQ9Im00NTEuMjggOTM0Ljk3Yy00Ni43NjQgMC04NC42NzMtMTAuMTcxLTg0LjY3My0yMi43MTcgMC02LjYwOTkgMTAuNTI1LTEyLjU2MSAyNy4zMjItMTYuNzEyIDAuMDM2MSAwLjAyOTcgMC4wNzA5IDAuMDYgMC4xMDcgMC4wODk5LTE2LjY2OSA0LjEzNTItMjcuMTA4IDEwLjA1My0yNy4xMDggMTYuNjIyIDAgMTIuNDk5IDM3Ljc2NiAyMi42MzEgODQuMzUyIDIyLjYzMSA0Ni41ODcgMCA4NC4zNTItMTAuMTMzIDg0LjM1Mi0yMi42MzEgMC02LjU2OTktMTAuNDM5LTEyLjQ4Ny0yNy4xMDgtMTYuNjIyIDAuMDM2Mi0wLjAyOTkgMC4wNzEyLTAuMDYwMSAwLjEwNjI1LTAuMDg5OSAxNi43OTYgNC4xNTE0IDI3LjMyMiAxMC4xMDIgMjcuMzIyIDE2LjcxMiAwIDEyLjU0Ni0zNy45MSAyMi43MTctODQuNjczIDIyLjcxNyIvPgogICA8cGF0aCBpZD0icGF0aDQyMDkiIGQ9Im00NTEuMjggOTM0Ljg4Yy00Ni41ODYgMC04NC4zNTItMTAuMTMzLTg0LjM1Mi0yMi42MzEgMC02LjU2OTkgMTAuNDM5LTEyLjQ4NyAyNy4xMDgtMTYuNjIyIDAuMDM1NiAwLjAzMTIgMC4wNzA4IDAuMDYxNSAwLjEwNjM4IDAuMDkxMy0xNi41NDEgNC4xMTc2LTI2Ljg5NCAxMC0yNi44OTQgMTYuNTMxIDAgMTIuNDUxIDM3LjYyMiAyMi41NDUgODQuMDMxIDIyLjU0NXM4NC4wMy0xMC4wOTQgODQuMDMtMjIuNTQ1YzAtNi41MzEyLTEwLjM1Mi0xMi40MTQtMjYuODk0LTE2LjUzMSAwLjAzNjItMC4wMjk4IDAuMDcxMi0wLjA2IDAuMTA3NS0wLjA5MTMgMTYuNjY5IDQuMTM1MiAyNy4xMDggMTAuMDUzIDI3LjEwOCAxNi42MjIgMCAxMi40OTktMzcuNzY1IDIyLjYzMS04NC4zNTIgMjIuNjMxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDIxMSIgZD0ibTQ1MS4yOCA5MzQuOGMtNDYuNDA5IDAtODQuMDMxLTEwLjA5NC04NC4wMzEtMjIuNTQ1IDAtNi41MzEyIDEwLjM1My0xMi40MTQgMjYuODk0LTE2LjUzMSAwLjAzNjEgMC4wMjk3IDAuMDcxOCAwLjA2IDAuMTA3NSAwLjA4OTktMTYuNDE0IDQuMTAxNS0yNi42OCA5Ljk1MDEtMjYuNjggMTYuNDQxIDAgMTIuNDA0IDM3LjQ3OCAyMi40NTkgODMuNzEgMjIuNDU5czgzLjcxLTEwLjA1NSA4My43MS0yMi40NTljMC02LjQ5MTItMTAuMjY2LTEyLjM0LTI2LjY4LTE2LjQ0MSAwLjAzNS0wLjAyOTkgMC4wNzEyLTAuMDYwMSAwLjEwNjI1LTAuMDg5OSAxNi41NDEgNC4xMTc2IDI2Ljg5NCAxMCAyNi44OTQgMTYuNTMxIDAgMTIuNDUxLTM3LjYyMSAyMi41NDUtODQuMDMgMjIuNTQ1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDIxMyIgZD0ibTQ1MS4yOCA5MzQuNzFjLTQ2LjIzMiAwLTgzLjcxLTEwLjA1NS04My43MS0yMi40NTkgMC02LjQ5MTIgMTAuMjY2LTEyLjM0IDI2LjY4LTE2LjQ0MSAwLjAzNjEgMC4wMzEyIDAuMDcxOCAwLjA2MTUgMC4xMDc4NyAwLjA5MTItMTYuMjg4IDQuMDg0LTI2LjQ2NyA5Ljg5NzUtMjYuNDY3IDE2LjM1IDAgMTIuMzU2IDM3LjMzNSAyMi4zNzMgODMuMzkgMjIuMzczIDQ2LjA1NCAwIDgzLjM4OS0xMC4wMTYgODMuMzg5LTIyLjM3MyAwLTYuNDUyNi0xMC4xNzktMTIuMjY2LTI2LjQ2OC0xNi4zNSAwLjAzNjItMC4wMjk3IDAuMDcyNS0wLjA2IDAuMTA4NzUtMC4wOTEyIDE2LjQxNCA0LjEwMTUgMjYuNjggOS45NTAxIDI2LjY4IDE2LjQ0MSAwIDEyLjQwNC0zNy40NzkgMjIuNDU5LTgzLjcxIDIyLjQ1OSIvPgogICA8cGF0aCBpZD0icGF0aDQyMTUiIGQ9Im00NTEuMjggOTM0LjYyYy00Ni4wNTUgMC04My4zOS0xMC4wMTYtODMuMzktMjIuMzczIDAtNi40NTI2IDEwLjE3OS0xMi4yNjYgMjYuNDY3LTE2LjM1IDAuMDM2MSAwLjAzMDMgMC4wNzE4IDAuMDYwMSAwLjEwNzg4IDAuMDkxNC0xNi4xNTkgNC4wNjY0LTI2LjI1NCA5Ljg0NjEtMjYuMjU0IDE2LjI1OSAwIDEyLjMwOSAzNy4xOTEgMjIuMjg4IDgzLjA2OSAyMi4yODhzODMuMDY5LTkuOTc5IDgzLjA2OS0yMi4yODhjMC02LjQxMjYtMTAuMDk2LTEyLjE5Mi0yNi4yNTUtMTYuMjU5IDAuMDM2Mi0wLjAzMTIgMC4wNzI1LTAuMDYxMSAwLjEwNzUtMC4wOTE0IDE2LjI4OSA0LjA4NCAyNi40NjggOS44OTc1IDI2LjQ2OCAxNi4zNSAwIDEyLjM1Ni0zNy4zMzUgMjIuMzczLTgzLjM4OSAyMi4zNzMiLz4KICAgPHBhdGggaWQ9InBhdGg0MjE3IiBkPSJtNDUxLjI4IDkzNC41NGMtNDUuODc4IDAtODMuMDY5LTkuOTc5LTgzLjA2OS0yMi4yODggMC02LjQxMjYgMTAuMDk1LTEyLjE5MiAyNi4yNTQtMTYuMjU5IDAuMDM2MSAwLjAzMDMgMC4wNzIyIDAuMDYgMC4xMDgzNyAwLjA4OTgtMTYuMDMxIDQuMDQ4OS0yNi4wNDIgOS43OTY1LTI2LjA0MiAxNi4xNjkgMCAxMi4yNjEgMzcuMDQ3IDIyLjIwMSA4Mi43NDggMjIuMjAxIDQ1LjcgMCA4Mi43NDgtOS45NCA4Mi43NDgtMjIuMjAxIDAtNi4zNzI1LTEwLjAxMS0xMi4xMi0yNi4wNDItMTYuMTY5IDAuMDM2Mi0wLjAyOTggMC4wNzI1LTAuMDU5NSAwLjEwODc1LTAuMDg5OCAxNi4xNTkgNC4wNjY0IDI2LjI1NSA5Ljg0NjEgMjYuMjU1IDE2LjI1OSAwIDEyLjMwOS0zNy4xOTEgMjIuMjg4LTgzLjA2OSAyMi4yODgiLz4KICAgPHBhdGggaWQ9InBhdGg0MjE5IiBkPSJtNDUxLjI4IDkzNC40NWMtNDUuNzAxIDAtODIuNzQ4LTkuOTQtODIuNzQ4LTIyLjIwMSAwLTYuMzcyNSAxMC4wMTEtMTIuMTIgMjYuMDQyLTE2LjE2OSAwLjAzNjEgMC4wMzA0IDAuMDcyNyAwLjA2MTYgMC4xMDg4OCAwLjA5MTQtMTUuOTA2IDQuMDMxMi0yNS44MyA5Ljc0NDEtMjUuODMgMTYuMDc4IDAgMTIuMjE0IDM2LjkwNCAyMi4xMTUgODIuNDI3IDIyLjExNXM4Mi40MjctOS45MDA5IDgyLjQyNy0yMi4xMTVjMC02LjMzMzUtOS45MjM4LTEyLjA0Ni0yNS44My0xNi4wNzggMC4wMzYyLTAuMDI5OCAwLjA3MjUtMC4wNjEgMC4xMDg3NS0wLjA5MTQgMTYuMDMxIDQuMDQ4OSAyNi4wNDIgOS43OTY1IDI2LjA0MiAxNi4xNjkgMCAxMi4yNjEtMzcuMDQ4IDIyLjIwMS04Mi43NDggMjIuMjAxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDIyMSIgZD0ibTQ1MS4yOCA5MzQuMzdjLTQ1LjUyNCAwLTgyLjQyNy05LjkwMDktODIuNDI3LTIyLjExNSAwLTYuMzMzNSA5LjkyMzgtMTIuMDQ2IDI1LjgzLTE2LjA3OCAwLjAzNjIgMC4wMzAzIDAuMDcyNyAwLjA2MTUgMC4xMDkgMC4wOTEzLTE1Ljc3OSA0LjAxMzgtMjUuNjE4IDkuNjkxNS0yNS42MTggMTUuOTg2IDAgMTIuMTY2IDM2Ljc2IDIyLjAyOSA4Mi4xMDYgMjIuMDI5czgyLjEwNy05Ljg2MjggODIuMTA3LTIyLjAyOWMwLTYuMjk0OS05Ljg0LTExLjk3My0yNS42MTktMTUuOTg2IDAuMDM2Mi0wLjAyOTcgMC4wNzI1LTAuMDYxIDAuMTA4NzUtMC4wOTEzIDE1LjkwNiA0LjAzMTIgMjUuODMgOS43NDQxIDI1LjgzIDE2LjA3OCAwIDEyLjIxNC0zNi45MDQgMjIuMTE1LTgyLjQyNyAyMi4xMTUiLz4KICAgPHBhdGggaWQ9InBhdGg0MjIzIiBkPSJtNDUxLjI4IDkzNC4yOGMtNDUuMzQ2IDAtODIuMTA2LTkuODYyOC04Mi4xMDYtMjIuMDI5IDAtNi4yOTQ5IDkuODM5NC0xMS45NzMgMjUuNjE4LTE1Ljk4NiAwLjAzNjEgMC4wMzA0IDAuMDczMSAwLjA2MDEgMC4xMDkzNyAwLjA4OTktMTUuNjUyIDMuOTk3Ni0yNS40MDcgOS42NDE2LTI1LjQwNyAxNS44OTYgMCAxMi4xMTkgMzYuNjE3IDIxLjk0MiA4MS43ODYgMjEuOTQyczgxLjc4Ni05LjgyMzcgODEuNzg2LTIxLjk0MmMwLTYuMjU0OS05Ljc1NS0xMS44OTktMjUuNDA2LTE1Ljg5NiAwLjAzNjItMC4wMjk3IDAuMDcyNS0wLjA1OTUgMC4xMDg3NS0wLjA4OTkgMTUuNzc5IDQuMDEzOCAyNS42MTkgOS42OTE1IDI1LjYxOSAxNS45ODYgMCAxMi4xNjYtMzYuNzYxIDIyLjAyOS04Mi4xMDcgMjIuMDI5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDIyNSIgZD0ibTQ1MS4yOCA5MzQuMTljLTQ1LjE3IDAtODEuNzg2LTkuODIzNy04MS43ODYtMjEuOTQyIDAtNi4yNTQ5IDkuNzU0OS0xMS44OTkgMjUuNDA3LTE1Ljg5NiAwLjAzNjEgMC4wMzAzIDAuMDczNiAwLjA2MTUgMC4xMDk3NSAwLjA5MTQtMTUuNTI2IDMuOTc5OS0yNS4xOTYgOS41ODg5LTI1LjE5NiAxNS44MDUgMCAxMi4wNzEgMzYuNDczIDIxLjg1NiA4MS40NjUgMjEuODU2czgxLjQ2Ni05Ljc4NTIgODEuNDY2LTIxLjg1NmMwLTYuMjE2Mi05LjY3LTExLjgyNS0yNS4xOTYtMTUuODA1IDAuMDM2Mi0wLjAyOTkgMC4wNzM4LTAuMDYxMSAwLjExLTAuMDkxNCAxNS42NTEgMy45OTc2IDI1LjQwNiA5LjY0MTYgMjUuNDA2IDE1Ljg5NiAwIDEyLjExOS0zNi42MTYgMjEuOTQyLTgxLjc4NiAyMS45NDIiLz4KICAgPHBhdGggaWQ9InBhdGg0MjI3IiBkPSJtNDUxLjI4IDkzNC4xMWMtNDQuOTkyIDAtODEuNDY1LTkuNzg1Mi04MS40NjUtMjEuODU2IDAtNi4yMTYyIDkuNjctMTEuODI1IDI1LjE5Ni0xNS44MDUgMC4wMzYyIDAuMDMwMiAwLjA3MzggMC4wNjE1IDAuMTEwMzggMC4wOTEyLTE1LjM5OCAzLjk2MTQtMjQuOTg2IDkuNTM3Ni0yNC45ODYgMTUuNzE0IDAgMTIuMDI0IDM2LjMzIDIxLjc3IDgxLjE0NSAyMS43NyA0NC44MTQgMCA4MS4xNDQtOS43NDYxIDgxLjE0NC0yMS43NyAwLTYuMTc2Mi05LjU4NzUtMTEuNzUyLTI0Ljk4Ni0xNS43MTQgMC4wMzYyLTAuMDI5NyAwLjA3NS0wLjA2MSAwLjExMTI1LTAuMDkxMiAxNS41MjYgMy45Nzk5IDI1LjE5NiA5LjU4ODkgMjUuMTk2IDE1LjgwNSAwIDEyLjA3MS0zNi40NzQgMjEuODU2LTgxLjQ2NiAyMS44NTYiLz4KICAgPHBhdGggaWQ9InBhdGg0MjI5IiBkPSJtNDUxLjI4IDkzNC4wMmMtNDQuODE1IDAtODEuMTQ1LTkuNzQ2MS04MS4xNDUtMjEuNzcgMC02LjE3NjIgOS41ODc5LTExLjc1MiAyNC45ODYtMTUuNzE0IDAuMDM2MSAwLjAyODkgMC4wNzQyIDAuMDYwMSAwLjExMDM3IDAuMDkwNC0xNS4yNzMgMy45NDQ4LTI0Ljc3NSA5LjQ4NTgtMjQuNzc1IDE1LjYyNCAwIDExLjk3NiAzNi4xODYgMjEuNjg1IDgwLjgyNCAyMS42ODVzODAuODIzLTkuNzA5IDgwLjgyMy0yMS42ODVjMC02LjEzNzgtOS41MDI1LTExLjY3OS0yNC43NzUtMTUuNjI0IDAuMDM2Mi0wLjAzMDMgMC4wNzM4LTAuMDYxNSAwLjExLTAuMDkwNCAxNS4zOTkgMy45NjE0IDI0Ljk4NiA5LjUzNzYgMjQuOTg2IDE1LjcxNCAwIDEyLjAyNC0zNi4zMyAyMS43Ny04MS4xNDQgMjEuNzciLz4KICA8L2c+CiAgPGc+CiAgIDxwYXRoIGlkPSJwYXRoNDIzMSIgZD0ibTQ1MS4yOCA5MzMuOTRjLTQ0LjYzOCAwLTgwLjgyNC05LjcwOS04MC44MjQtMjEuNjg1IDAtNi4xMzc4IDkuNTAyNC0xMS42NzkgMjQuNzc1LTE1LjYyNCAwLjAzODYgMC4wMzA4IDAuMDcyMiAwLjA1OTUgMC4xMTEzOCAwLjA5MDctMTUuMTQ2IDMuOTI3OC0yNC41NjYgOS40MzUxLTI0LjU2NiAxNS41MzMgMCAxMS45MjkgMzYuMDQyIDIxLjU5OSA4MC41MDMgMjEuNTk5IDQ0LjQ2IDAgODAuNTAzLTkuNjY5OSA4MC41MDMtMjEuNTk5IDAtNi4wOTc2LTkuNDItMTEuNjA1LTI0LjU2Ni0xNS41MzMgMC4wMzg3LTAuMDMxMiAwLjA3MjUtMC4wNiAwLjExMTI1LTAuMDkwNyAxNS4yNzIgMy45NDQ4IDI0Ljc3NSA5LjQ4NTggMjQuNzc1IDE1LjYyNCAwIDExLjk3Ni0zNi4xODUgMjEuNjg1LTgwLjgyMyAyMS42ODUiIGZpbGw9IiNmZWZmZmYiLz4KICAgPHBhdGggaWQ9InBhdGg0MjMzIiBkPSJtNDUxLjI4IDkzMy44NWMtNDQuNDYgMC04MC41MDMtOS42Njk5LTgwLjUwMy0yMS41OTkgMC02LjA5NzYgOS40Mi0xMS42MDUgMjQuNTY2LTE1LjUzMyAwLjAzNjEgMC4wMzAzIDAuMDc0NiAwLjA2MTUgMC4xMTEyNSAwLjA5MTQtMTUuMDIyIDMuOTA4Ni0yNC4zNTYgOS4zODI4LTI0LjM1NiAxNS40NDEgMCAxMS44ODEgMzUuODk4IDIxLjUxMyA4MC4xODIgMjEuNTEzIDQ0LjI4MyAwIDgwLjE4Mi05LjYzMTQgODAuMTgyLTIxLjUxMyAwLTYuMDU4Ni05LjMzMzgtMTEuNTMzLTI0LjM1Ni0xNS40NDEgMC4wMzYyLTAuMDI5OSAwLjA3NS0wLjA2MTEgMC4xMTEyNS0wLjA5MTQgMTUuMTQ2IDMuOTI3OCAyNC41NjYgOS40MzUxIDI0LjU2NiAxNS41MzMgMCAxMS45MjktMzYuMDQyIDIxLjU5OS04MC41MDMgMjEuNTk5IiBmaWxsPSIjZmVmZmZmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDIzNSIgZD0ibTQ1MS4yOCA5MzMuNzdjLTQ0LjI4NCAwLTgwLjE4Mi05LjYzMTQtODAuMTgyLTIxLjUxMyAwLTYuMDU4NiA5LjMzNDUtMTEuNTMzIDI0LjM1Ni0xNS40NDEgMC4wMzY2IDAuMDI4NyAwLjA3NTIgMC4wNjE1IDAuMTExODcgMC4wOTAyLTE0Ljg5NiAzLjg5MjEtMjQuMTQ3IDkuMzMyNi0yNC4xNDcgMTUuMzUxIDAgMTEuODM0IDM1Ljc1NSAyMS40MjYgNzkuODYxIDIxLjQyNnM3OS44Ni05LjU5MjMgNzkuODYtMjEuNDI2YzAtNi4wMTg1LTkuMjUxMi0xMS40NTktMjQuMTQ2LTE1LjM1MSAwLjAzNjItMC4wMjg3IDAuMDc1LTAuMDYxNSAwLjExMTI1LTAuMDkwMiAxNS4wMjIgMy45MDg2IDI0LjM1NiA5LjM4MjggMjQuMzU2IDE1LjQ0MSAwIDExLjg4MS0zNS44OTkgMjEuNTEzLTgwLjE4MiAyMS41MTMiIGZpbGw9IiNmZWZlZmYiLz4KICAgPHBhdGggaWQ9InBhdGg0MjM3IiBkPSJtNDUxLjI4IDkzMy42OGMtNDQuMTA2IDAtNzkuODYxLTkuNTkyMy03OS44NjEtMjEuNDI2IDAtNi4wMTg1IDkuMjUxNS0xMS40NTkgMjQuMTQ3LTE1LjM1MSAwLjAzODUgMC4wMzEyIDAuMDczMiAwLjA2MDEgMC4xMTE3NSAwLjA5MDktMTQuNzcgMy44NzU1LTIzLjkzOCA5LjI4MDItMjMuOTM4IDE1LjI2IDAgMTEuNzg2IDM1LjYxMSAyMS4zNCA3OS41NDEgMjEuMzQgNDMuOTI5IDAgNzkuNTQtOS41NTM3IDc5LjU0LTIxLjM0IDAtNS45OC05LjE2ODgtMTEuMzg1LTIzLjkzOS0xNS4yNiAwLjAzODctMC4wMzA4IDAuMDczNy0wLjA1OTYgMC4xMTI1LTAuMDkwOSAxNC44OTUgMy44OTIxIDI0LjE0NiA5LjMzMjYgMjQuMTQ2IDE1LjM1MSAwIDExLjgzNC0zNS43NTUgMjEuNDI2LTc5Ljg2IDIxLjQyNiIgZmlsbD0iI2ZlZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDQyMzkiIGQ9Im00NTEuMjggOTMzLjU5Yy00My45MjkgMC03OS41NDEtOS41NTM3LTc5LjU0MS0yMS4zNCAwLTUuOTggOS4xNjc5LTExLjM4NSAyMy45MzgtMTUuMjYgMC4wMzY2IDAuMDMwMyAwLjA3NjMgMC4wNjE1IDAuMTEyODggMC4wOTEzLTE0LjY0NiAzLjg1NjUtMjMuNzMgOS4yMjc2LTIzLjczIDE1LjE2OSAwIDExLjczOSAzNS40NjggMjEuMjU1IDc5LjIyIDIxLjI1NXM3OS4yMTktOS41MTYxIDc5LjIxOS0yMS4yNTVjMC01Ljk0MTQtOS4wODUtMTEuMzEyLTIzLjczLTE1LjE2OSAwLjAzNjMtMC4wMjk4IDAuMDc2My0wLjA2MSAwLjExMjUtMC4wOTEzIDE0Ljc3IDMuODc1NSAyMy45MzkgOS4yODAyIDIzLjkzOSAxNS4yNiAwIDExLjc4Ni0zNS42MTEgMjEuMzQtNzkuNTQgMjEuMzQiIGZpbGw9IiNmZWZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg0MjQxIiBkPSJtNDUxLjI4IDkzMy41MWMtNDMuNzUyIDAtNzkuMjItOS41MTYxLTc5LjIyLTIxLjI1NSAwLTUuOTQxNCA5LjA4NDUtMTEuMzEyIDIzLjczLTE1LjE2OSAwLjAzODUgMC4wMzEyIDAuMDczOCAwLjA1OTEgMC4xMTI3NSAwLjA5MDQtMTQuNTIyIDMuODM5OS0yMy41MjIgOS4xNzcyLTIzLjUyMiAxNS4wNzkgMCAxMS42OTEgMzUuMzI0IDIxLjE2OSA3OC44OTkgMjEuMTY5IDQzLjU3NCAwIDc4Ljg5OS05LjQ3NzYgNzguODk5LTIxLjE2OSAwLTUuOTAxNC05LjAwMTItMTEuMjM5LTIzLjUyMi0xNS4wNzkgMC4wMzg3LTAuMDMxMiAwLjA3MzctMC4wNTkxIDAuMTEyNS0wLjA5MDQgMTQuNjQ1IDMuODU2NSAyMy43MyA5LjIyNzYgMjMuNzMgMTUuMTY5IDAgMTEuNzM5LTM1LjQ2OCAyMS4yNTUtNzkuMjE5IDIxLjI1NSIgZmlsbD0iI2ZkZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDQyNDMiIGQ9Im00NTEuMjggOTMzLjQyYy00My41NzUgMC03OC44OTktOS40Nzc2LTc4Ljg5OS0yMS4xNjkgMC01LjkwMTQgOS4wMDEtMTEuMjM5IDIzLjUyMi0xNS4wNzkgMC4wMzY2IDAuMDI5NyAwLjA3NjEgMC4wNjI1IDAuMTEzMjUgMC4wOTE0LTE0LjM5NSAzLjgyMDgtMjMuMzE1IDkuMTI1LTIzLjMxNSAxNC45ODcgMCAxMS42NDQgMzUuMTgxIDIxLjA4MyA3OC41NzkgMjEuMDgzczc4LjU3OC05LjQzOSA3OC41NzgtMjEuMDgzYzAtNS44NjIyLTguOTItMTEuMTY2LTIzLjMxNS0xNC45ODcgMC4wMzc1LTAuMDI4OSAwLjA3NjMtMC4wNjE2IDAuMTEzNzUtMC4wOTE0IDE0LjUyMSAzLjgzOTkgMjMuNTIyIDkuMTc3MiAyMy41MjIgMTUuMDc5IDAgMTEuNjkxLTM1LjMyNSAyMS4xNjktNzguODk5IDIxLjE2OSIgZmlsbD0iI2ZkZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDQyNDUiIGQ9Im00NTEuMjggOTMzLjMzYy00My4zOTggMC03OC41NzktOS40MzktNzguNTc5LTIxLjA4MyAwLTUuODYyMiA4LjkyMDQtMTEuMTY2IDIzLjMxNS0xNC45ODcgMC4wMzg2IDAuMDMxMiAwLjA3NDMgMC4wNiAwLjExMzM3IDAuMDkwNy0xNC4yNzIgMy44MDQyLTIzLjEwNyA5LjA3MjgtMjMuMTA3IDE0Ljg5NiAwIDExLjU5NiAzNS4wMzcgMjAuOTk2IDc4LjI1NyAyMC45OTZzNzguMjU3LTkuMzk5OSA3OC4yNTctMjAuOTk2YzAtNS44MjM4LTguODM1LTExLjA5Mi0yMy4xMDgtMTQuODk2IDAuMDM4OC0wLjAzMDggMC4wNzUtMC4wNTk1IDAuMTEzNzUtMC4wOTA3IDE0LjM5NSAzLjgyMDggMjMuMzE1IDkuMTI1IDIzLjMxNSAxNC45ODcgMCAxMS42NDQtMzUuMTggMjEuMDgzLTc4LjU3OCAyMS4wODMiIGZpbGw9IiNmZGZkZmUiLz4KICAgPHBhdGggaWQ9InBhdGg0MjQ3IiBkPSJtNDUxLjI4IDkzMy4yNWMtNDMuMjIxIDAtNzguMjU3LTkuMzk5OS03OC4yNTctMjAuOTk2IDAtNS44MjM4IDguODM2LTExLjA5MiAyMy4xMDctMTQuODk2IDAuMDM3IDAuMDI4OSAwLjA3NzYgMC4wNjE1IDAuMTE0MjUgMC4wOTE0LTE0LjE0NiAzLjc4NTEtMjIuOTAxIDkuMDIxNC0yMi45MDEgMTQuODA1IDAgMTEuNTQ5IDM0Ljg5MyAyMC45MSA3Ny45MzcgMjAuOTEgNDMuMDQzIDAgNzcuOTM3LTkuMzYxMyA3Ny45MzctMjAuOTEgMC01Ljc4MzgtOC43NTUtMTEuMDItMjIuOTAxLTE0LjgwNSAwLjAzNjItMC4wMjk5IDAuMDc3NS0wLjA2MjUgMC4xMTM3NS0wLjA5MTQgMTQuMjcyIDMuODA0MiAyMy4xMDggOS4wNzI4IDIzLjEwOCAxNC44OTYgMCAxMS41OTYtMzUuMDM2IDIwLjk5Ni03OC4yNTcgMjAuOTk2IiBmaWxsPSIjZmRmZGZkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI0OSIgZD0ibTQ1MS4yOCA5MzMuMTZjLTQzLjA0NCAwLTc3LjkzNy05LjM2MTMtNzcuOTM3LTIwLjkxIDAtNS43ODM4IDguNzU0OS0xMS4wMiAyMi45MDEtMTQuODA1IDAuMDM5IDAuMDMxMiAwLjA3NTEgMC4wNiAwLjExNDc1IDAuMDkwMi0xNC4wMjUgMy43Njg2LTIyLjY5NSA4Ljk2OTctMjIuNjk1IDE0LjcxNSAwIDExLjUwMSAzNC43NSAyMC44MjQgNzcuNjE2IDIwLjgyNHM3Ny42MTYtOS4zMjIzIDc3LjYxNi0yMC44MjRjMC01Ljc0NTEtOC42Ny0xMC45NDYtMjIuNjk1LTE0LjcxNSAwLjA0LTAuMDMwMyAwLjA3NjMtMC4wNTkgMC4xMTUtMC4wOTAyIDE0LjE0NiAzLjc4NTEgMjIuOTAxIDkuMDIxNCAyMi45MDEgMTQuODA1IDAgMTEuNTQ5LTM0Ljg5NCAyMC45MS03Ny45MzcgMjAuOTEiIGZpbGw9IiNmZGZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg0MjUxIiBkPSJtNDUxLjI4IDkzMy4wOGMtNDIuODY2IDAtNzcuNjE2LTkuMzIyMy03Ny42MTYtMjAuODI0IDAtNS43NDUxIDguNjcwNC0xMC45NDYgMjIuNjk1LTE0LjcxNSAwLjAzNjYgMC4wMjk5IDAuMDc3NiAwLjA2MjUgMC4xMTQ3NSAwLjA5MTQtMTMuOSAzLjc1LTIyLjQ4OSA4LjkxNzUtMjIuNDg5IDE0LjYyNCAwIDExLjQ1NCAzNC42MDYgMjAuNzM3IDc3LjI5NSAyMC43MzdzNzcuMjk2LTkuMjgzNyA3Ny4yOTYtMjAuNzM3YzAtNS43MDYtOC41ODg4LTEwLjg3NC0yMi40OS0xNC42MjQgMC4wMzc1LTAuMDI4OSAwLjA3ODgtMC4wNjE1IDAuMTE1LTAuMDkxNCAxNC4wMjUgMy43Njg2IDIyLjY5NSA4Ljk2OTcgMjIuNjk1IDE0LjcxNSAwIDExLjUwMS0zNC43NSAyMC44MjQtNzcuNjE2IDIwLjgyNCIgZmlsbD0iI2ZjZmRmZCIvPgogICA8cGF0aCBpZD0icGF0aDQyNTMiIGQ9Im00NTEuMjggOTMyLjk5Yy00Mi42ODkgMC03Ny4yOTUtOS4yODM3LTc3LjI5NS0yMC43MzcgMC01LjcwNiA4LjU4ODgtMTAuODc0IDIyLjQ4OS0xNC42MjQgMC4wMzkgMC4wMzEyIDAuMDc2MSAwLjA1OTUgMC4xMTUxMyAwLjA5MDctMTMuNzc3IDMuNzMxNS0yMi4yODMgOC44NjUyLTIyLjI4MyAxNC41MzMgMCAxMS40MDYgMzQuNDYyIDIwLjY1MiA3Ni45NzQgMjAuNjUyczc2Ljk3NC05LjI0NjEgNzYuOTc0LTIwLjY1MmMwLTUuNjY3NS04LjUwNjItMTAuODAxLTIyLjI4NC0xNC41MzMgMC4wMzg3LTAuMDMxMiAwLjA3NjItMC4wNTk1IDAuMTE1LTAuMDkwNyAxMy45MDEgMy43NSAyMi40OSA4LjkxNzUgMjIuNDkgMTQuNjI0IDAgMTEuNDU0LTM0LjYwNiAyMC43MzctNzcuMjk2IDIwLjczNyIgZmlsbD0iI2ZjZmRmZCIvPgogICA8cGF0aCBpZD0icGF0aDQyNTUiIGQ9Im00NTEuMjggOTMyLjljLTQyLjUxMiAwLTc2Ljk3NC05LjI0NjEtNzYuOTc0LTIwLjY1MiAwLTUuNjY3NSA4LjUwNjQtMTAuODAxIDIyLjI4My0xNC41MzMgMC4wMzkxIDAuMDMxMiAwLjA3NjggMC4wNjAxIDAuMTE1NzUgMC4wOTE0LTEzLjY1MSAzLjcxMzktMjIuMDc5IDguODEzOS0yMi4wNzkgMTQuNDQxIDAgMTEuMzU5IDM0LjMxOSAyMC41NjYgNzYuNjU0IDIwLjU2NiA0Mi4zMzQgMCA3Ni42NTMtOS4yMDc1IDc2LjY1My0yMC41NjYgMC01LjYyNzUtOC40MjYyLTEwLjcyOC0yMi4wNzgtMTQuNDQxIDAuMDM4Ny0wLjAzMTIgMC4wNzYzLTAuMDYwMSAwLjExNS0wLjA5MTQgMTMuNzc4IDMuNzMxNSAyMi4yODQgOC44NjUyIDIyLjI4NCAxNC41MzMgMCAxMS40MDYtMzQuNDYyIDIwLjY1Mi03Ni45NzQgMjAuNjUyIiBmaWxsPSIjZmNmY2ZkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI1NyIgZD0ibTQ1MS4yOCA5MzIuODJjLTQyLjMzNSAwLTc2LjY1NC05LjIwNzUtNzYuNjU0LTIwLjU2NiAwLTUuNjI3NSA4LjQyNzItMTAuNzI4IDIyLjA3OS0xNC40NDEgMC4wMzcxIDAuMDI4NyAwLjA3OTEgMC4wNjI1IDAuMTE2MjUgMC4wOTEyLTEzLjUyOSAzLjY5NTQtMjEuODc0IDguNzYxMi0yMS44NzQgMTQuMzUgMCAxMS4zMTEgMzQuMTc1IDIwLjQ4IDc2LjMzMyAyMC40OCA0Mi4xNTcgMCA3Ni4zMzMtOS4xNjg5IDc2LjMzMy0yMC40OCAwLTUuNTg4OS04LjM0NS0xMC42NTUtMjEuODc0LTE0LjM1IDAuMDM2My0wLjAyODcgMC4wNzg4LTAuMDYyNSAwLjExNjI1LTAuMDkxMiAxMy42NTEgMy43MTM5IDIyLjA3OCA4LjgxMzkgMjIuMDc4IDE0LjQ0MSAwIDExLjM1OS0zNC4zMTkgMjAuNTY2LTc2LjY1MyAyMC41NjYiIGZpbGw9IiNmY2ZjZmMiLz4KICAgPHBhdGggaWQ9InBhdGg0MjU5IiBkPSJtNDUxLjI4IDkzMi43M2MtNDIuMTU4IDAtNzYuMzMzLTkuMTY4OS03Ni4zMzMtMjAuNDggMC01LjU4ODkgOC4zNDQ4LTEwLjY1NSAyMS44NzQtMTQuMzUgMC4wMzkgMC4wMzAzIDAuMDc3MSAwLjA2MDEgMC4xMTYyNSAwLjA5MTQtMTMuNDA1IDMuNjc2Mi0yMS42NyA4LjcwOS0yMS42NyAxNC4yNTkgMCAxMS4yNjQgMzQuMDMyIDIwLjM5NCA3Ni4wMTIgMjAuMzk0czc2LjAxMi05LjEyOTkgNzYuMDEyLTIwLjM5NGMwLTUuNTQ5OC04LjI2NS0xMC41ODItMjEuNjY5LTE0LjI1OSAwLjAzODctMC4wMzEyIDAuMDc2Mi0wLjA2MTEgMC4xMTYyNS0wLjA5MTQgMTMuNTI5IDMuNjk1NCAyMS44NzQgOC43NjEyIDIxLjg3NCAxNC4zNSAwIDExLjMxMS0zNC4xNzYgMjAuNDgtNzYuMzMzIDIwLjQ4IiBmaWxsPSIjZmJmY2ZjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI2MSIgZD0ibTQ1MS4yOCA5MzIuNjVjLTQxLjk4IDAtNzYuMDEyLTkuMTI5OS03Ni4wMTItMjAuMzk0IDAtNS41NDk4IDguMjY0Ni0xMC41ODIgMjEuNjctMTQuMjU5IDAuMDM5NSAwLjAyOTggMC4wNzc2IDAuMDYgMC4xMTcxMiAwLjA4OTgtMTMuMjg0IDMuNjYwMi0yMS40NjYgOC42NTc4LTIxLjQ2NiAxNC4xNjkgMCAxMS4yMTYgMzMuODg4IDIwLjMwOCA3NS42OTEgMjAuMzA4czc1LjY5Mi05LjA5MTQgNzUuNjkyLTIwLjMwOGMwLTUuNTExMi04LjE4MjUtMTAuNTA5LTIxLjQ2Ni0xNC4xNjkgMC4wMzg3LTAuMDI5OCAwLjA3NzUtMC4wNiAwLjExNzUtMC4wODk4IDEzLjQwNCAzLjY3NjIgMjEuNjY5IDguNzA5IDIxLjY2OSAxNC4yNTkgMCAxMS4yNjQtMzQuMDMxIDIwLjM5NC03Ni4wMTIgMjAuMzk0IiBmaWxsPSIjZmJmY2ZjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI2MyIgZD0ibTQ1MS4yOCA5MzIuNTZjLTQxLjgwMyAwLTc1LjY5MS05LjA5MTQtNzUuNjkxLTIwLjMwOCAwLTUuNTExMiA4LjE4MjEtMTAuNTA5IDIxLjQ2Ni0xNC4xNjkgMC4wMzk2IDAuMDMxMiAwLjA3ODEgMC4wNjE2IDAuMTE3NzUgMC4wOTE0LTEzLjE2MSAzLjY0MTEtMjEuMjYzIDguNjA1LTIxLjI2MyAxNC4wNzggMCAxMS4xNjkgMzMuNzQ1IDIwLjIyMSA3NS4zNzEgMjAuMjIxczc1LjM3LTkuMDUyMiA3NS4zNy0yMC4yMjFjMC01LjQ3MjYtOC4xMDEyLTEwLjQzNi0yMS4yNjItMTQuMDc4IDAuMDM4Ny0wLjAyOTggMC4wNzc1LTAuMDYwMSAwLjExNzUtMC4wOTE0IDEzLjI4NCAzLjY2MDIgMjEuNDY2IDguNjU3OCAyMS40NjYgMTQuMTY5IDAgMTEuMjE2LTMzLjg4OSAyMC4zMDgtNzUuNjkyIDIwLjMwOCIgZmlsbD0iI2ZiZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDQyNjUiIGQ9Im00NTEuMjggOTMyLjQ3Yy00MS42MjYgMC03NS4zNzEtOS4wNTIyLTc1LjM3MS0yMC4yMjEgMC01LjQ3MjYgOC4xMDE1LTEwLjQzNiAyMS4yNjMtMTQuMDc4IDAuMDM5IDAuMDMxMiAwLjA3ODEgMC4wNjE1IDAuMTE3NjMgMC4wOTEzLTEzLjAzOSAzLjYyMjYtMjEuMDYgOC41NTI4LTIxLjA2IDEzLjk4NiAwIDExLjEyIDMzLjYwMSAyMC4xMzYgNzUuMDUgMjAuMTM2czc1LjA0OS05LjAxNjEgNzUuMDQ5LTIwLjEzNmMwLTUuNDMzNi04LjAyLTEwLjM2NC0yMS4wNTktMTMuOTg2IDAuMDM4OC0wLjAyOTcgMC4wNzc1LTAuMDYgMC4xMTc1LTAuMDkxMyAxMy4xNjEgMy42NDExIDIxLjI2MiA4LjYwNSAyMS4yNjIgMTQuMDc4IDAgMTEuMTY5LTMzLjc0NSAyMC4yMjEtNzUuMzcgMjAuMjIxIiBmaWxsPSIjZmJmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI2NyIgZD0ibTQ1MS4yOCA5MzIuMzljLTQxLjQ0OSAwLTc1LjA1LTkuMDE2MS03NS4wNS0yMC4xMzYgMC01LjQzMzYgOC4wMjEtMTAuMzY0IDIxLjA2LTEzLjk4NiAwLjAzOSAwLjAzMDQgMC4wNzg2IDAuMDYxNiAwLjExODEyIDAuMDkxNC0xMi45MTcgMy42MDQtMjAuODU3IDguNS0yMC44NTcgMTMuODk1IDAgMTEuMDcyIDMzLjQ1NyAyMC4wNSA3NC43MjkgMjAuMDVzNzQuNzI5LTguOTc3NiA3NC43MjktMjAuMDVjMC01LjM5NS03Ljk0LTEwLjI5MS0yMC44NTgtMTMuODk1IDAuMDQtMC4wMjk4IDAuMDc4Ny0wLjA2MSAwLjExODc1LTAuMDkxNCAxMy4wMzkgMy42MjI2IDIxLjA1OSA4LjU1MjggMjEuMDU5IDEzLjk4NiAwIDExLjEyLTMzLjYgMjAuMTM2LTc1LjA0OSAyMC4xMzYiIGZpbGw9IiNmYWZiZmIiLz4KICAgPHBhdGggaWQ9InBhdGg0MjY5IiBkPSJtNDUxLjI4IDkzMi4zYy00MS4yNzIgMC03NC43MjktOC45Nzc2LTc0LjcyOS0yMC4wNSAwLTUuMzk1IDcuOTM5OS0xMC4yOTEgMjAuODU3LTEzLjg5NSAwLjAzOTYgMC4wMjk3IDAuMDc5MSAwLjA2MTUgMC4xMTg2MyAwLjA5MTItMTIuNzkzIDMuNTg1LTIwLjY1NSA4LjQ0ODgtMjAuNjU1IDEzLjgwNCAwIDExLjAyNSAzMy4zMTQgMTkuOTY0IDc0LjQwOCAxOS45NjRzNzQuNDA4LTguOTM5IDc0LjQwOC0xOS45NjRjMC01LjM1NjUtNy44NTg4LTEwLjIxOS0yMC42NTUtMTMuODA0IDAuMDQtMC4wMjk4IDAuMDc4Ny0wLjA2MTUgMC4xMTg3NS0wLjA5MTIgMTIuOTE4IDMuNjA0IDIwLjg1OCA4LjUgMjAuODU4IDEzLjg5NSAwIDExLjA3Mi0zMy40NTggMjAuMDUtNzQuNzI5IDIwLjA1IiBmaWxsPSIjZmFmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDI3MSIgZD0ibTQ1MS4yOCA5MzIuMjJjLTQxLjA5NSAwLTc0LjQwOC04LjkzOS03NC40MDgtMTkuOTY0IDAtNS4zNTUgNy44NjE5LTEwLjIxOSAyMC42NTUtMTMuODA0IDAuMDM5NiAwLjAyOTkgMC4wNzkxIDAuMDYxMSAwLjExOTI1IDAuMDkxNC0xMi42NzMgMy41NjU5LTIwLjQ1MyA4LjM5Ni0yMC40NTMgMTMuNzEyIDAgMTAuOTc4IDMzLjE3IDE5Ljg3NyA3NC4wODcgMTkuODc3czc0LjA4OC04Ljg5OTkgNzQuMDg4LTE5Ljg3N2MwLTUuMzE2NC03Ljc4MTItMTAuMTQ2LTIwLjQ1NC0xMy43MTIgMC4wNC0wLjAzMDIgMC4wOC0wLjA2MTUgMC4xMTg3NS0wLjA5MTQgMTIuNzk2IDMuNTg1IDIwLjY1NSA4LjQ0NzIgMjAuNjU1IDEzLjgwNCAwIDExLjAyNS0zMy4zMTQgMTkuOTY0LTc0LjQwOCAxOS45NjQiIGZpbGw9IiNmYWZhZmEiLz4KICAgPHBhdGggaWQ9InBhdGg0MjczIiBkPSJtNDUxLjI4IDkzMi4xM2MtNDAuOTE4IDAtNzQuMDg3LTguODk5OS03NC4wODctMTkuODc3IDAtNS4zMTY0IDcuNzgwMi0xMC4xNDYgMjAuNDUzLTEzLjcxMiAwLjAzOTUgMC4wMjk3IDAuMDc5NSAwLjA2MSAwLjExOTYyIDAuMDkxMy0xMi41NSAzLjU0NzQtMjAuMjUyIDguMzQzOC0yMC4yNTIgMTMuNjIxIDAgMTAuOTMgMzMuMDI2IDE5Ljc5MSA3My43NjcgMTkuNzkxIDQwLjc0IDAgNzMuNzY3LTguODYwOSA3My43NjctMTkuNzkxIDAtNS4yNzc0LTcuNzAyNS0xMC4wNzQtMjAuMjUyLTEzLjYyMSAwLjA0LTAuMDMwMyAwLjA4LTAuMDYxNSAwLjEyLTAuMDkxMyAxMi42NzIgMy41NjU5IDIwLjQ1NCA4LjM5NiAyMC40NTQgMTMuNzEyIDAgMTAuOTc4LTMzLjE3MSAxOS44NzctNzQuMDg4IDE5Ljg3NyIgZmlsbD0iI2ZhZmFmYSIvPgogICA8cGF0aCBpZD0icGF0aDQyNzUiIGQ9Im00NTEuMjggOTMyLjA0Yy00MC43NCAwLTczLjc2Ny04Ljg2MDktNzMuNzY3LTE5Ljc5MSAwLTUuMjc3NCA3LjcwMTYtMTAuMDc0IDIwLjI1Mi0xMy42MjEgMC4wMzk1IDAuMDI5OSAwLjA4IDAuMDYxMSAwLjExOTYzIDAuMDkxNC0xMi40MzEgMy41MjgyLTIwLjA1MSA4LjI5MS0yMC4wNTEgMTMuNTMgMCAxMC44ODIgMzIuODgzIDE5LjcwNSA3My40NDYgMTkuNzA1czczLjQ0Ni04LjgyMjcgNzMuNDQ2LTE5LjcwNWMwLTUuMjM4OC03LjYyLTEwLjAwMi0yMC4wNTEtMTMuNTMgMC4wNC0wLjAzMDMgMC4wOC0wLjA2MTUgMC4xMi0wLjA5MTQgMTIuNTUgMy41NDc0IDIwLjI1MiA4LjM0MzggMjAuMjUyIDEzLjYyMSAwIDEwLjkzLTMzLjAyNiAxOS43OTEtNzMuNzY3IDE5Ljc5MSIgZmlsbD0iI2Y5ZmFmYSIvPgogICA8cGF0aCBpZD0icGF0aDQyNzciIGQ9Im00NTEuMjggOTMxLjk2Yy00MC41NjMgMC03My40NDYtOC44MjI3LTczLjQ0Ni0xOS43MDUgMC01LjIzODggNy42MjAxLTEwLjAwMiAyMC4wNTEtMTMuNTMgMC4wNCAwLjAyOTggMC4wODA1IDAuMDYxIDAuMTIwNSAwLjA5MDctMTIuMzEgMy41MTAyLTE5Ljg1MSA4LjIzODgtMTkuODUxIDEzLjQzOSAwIDEwLjgzNSAzMi43MzkgMTkuNjE5IDczLjEyNiAxOS42MTkgNDAuMzg2IDAgNzMuMTI2LTguNzgzNiA3My4xMjYtMTkuNjE5IDAtNS4yMDAyLTcuNTQxMi05LjkyODgtMTkuODUxLTEzLjQzOSAwLjA0LTAuMDI5NyAwLjA4MTMtMC4wNjEgMC4xMi0wLjA5MDcgMTIuNDMxIDMuNTI4MiAyMC4wNTEgOC4yOTEgMjAuMDUxIDEzLjUzIDAgMTAuODgyLTMyLjg4MiAxOS43MDUtNzMuNDQ2IDE5LjcwNSIgZmlsbD0iI2Y5ZjlmOSIvPgogICA8cGF0aCBpZD0icGF0aDQyNzkiIGQ9Im00NTEuMjggOTMxLjg3Yy00MC4zODYgMC03My4xMjYtOC43ODM2LTczLjEyNi0xOS42MTkgMC01LjIwMDIgNy41NDA1LTkuOTI4OCAxOS44NTEtMTMuNDM5IDAuMDQwMSAwLjAzMDMgMC4wODExIDAuMDYxNSAwLjEyMDYyIDAuMDkxNC0xMi4xODcgMy40OTExLTE5LjY1MSA4LjE4NjUtMTkuNjUxIDEzLjM0OCAwIDEwLjc4OCAzMi41OTYgMTkuNTM0IDcyLjgwNSAxOS41MzQgNDAuMjA4IDAgNzIuODA0LTguNzQ2MSA3Mi44MDQtMTkuNTM0IDAtNS4xNjExLTcuNDYzOC05Ljg1NjUtMTkuNjUxLTEzLjM0OCAwLjA0LTAuMDI5OSAwLjA4MTItMC4wNjExIDAuMTIxMjUtMC4wOTE0IDEyLjMxIDMuNTEwMiAxOS44NTEgOC4yMzg4IDE5Ljg1MSAxMy40MzkgMCAxMC44MzUtMzIuNzQgMTkuNjE5LTczLjEyNiAxOS42MTkiIGZpbGw9IiNmOWY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg0MjgxIiBkPSJtNDUxLjI4IDkzMS43OWMtNDAuMjA5IDAtNzIuODA1LTguNzQ2MS03Mi44MDUtMTkuNTM0IDAtNS4xNjExIDcuNDYzOS05Ljg1NjUgMTkuNjUxLTEzLjM0OCAwLjA0MiAwLjAzMTIgMC4wNzk2IDAuMDU5IDAuMTIxNjMgMC4wOTEyLTEyLjA2OCAzLjQ3MjYtMTkuNDUyIDguMTMzOS0xOS40NTIgMTMuMjU2IDAgMTAuNzQgMzIuNDUyIDE5LjQ0OCA3Mi40ODQgMTkuNDQ4czcyLjQ4NC04LjcwNzUgNzIuNDg0LTE5LjQ0OGMwLTUuMTIyNS03LjM4NS05Ljc4MzgtMTkuNDUyLTEzLjI1NiAwLjA0MjUtMC4wMzIyIDAuMDgtMC4wNiAwLjEyMTI1LTAuMDkxMiAxMi4xODggMy40OTExIDE5LjY1MSA4LjE4NjUgMTkuNjUxIDEzLjM0OCAwIDEwLjc4OC0zMi41OTYgMTkuNTM0LTcyLjgwNCAxOS41MzQiIGZpbGw9IiNmOGY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg0MjgzIiBkPSJtNDUxLjI4IDkzMS43Yy00MC4wMzIgMC03Mi40ODQtOC43MDc1LTcyLjQ4NC0xOS40NDggMC01LjEyMjUgNy4zODM3LTkuNzgzOCAxOS40NTItMTMuMjU2IDAuMDQgMC4wMzAzIDAuMDgxNSAwLjA2MTUgMC4xMjE2MiAwLjA5MTQtMTEuOTQ5IDMuNDUzNi0xOS4yNTIgOC4wOC0xOS4yNTIgMTMuMTY1IDAgMTAuNjkyIDMyLjMwOSAxOS4zNjEgNzIuMTYzIDE5LjM2MXM3Mi4xNjMtOC42NjkgNzIuMTYzLTE5LjM2MWMwLTUuMDg1LTcuMzAzOC05LjcxMTQtMTkuMjUyLTEzLjE2NSAwLjA0LTAuMDI5OSAwLjA4MTItMC4wNjExIDAuMTIxMjUtMC4wOTE0IDEyLjA2OCAzLjQ3MjYgMTkuNDUyIDguMTMzOSAxOS40NTIgMTMuMjU2IDAgMTAuNzQtMzIuNDUyIDE5LjQ0OC03Mi40ODQgMTkuNDQ4IiBmaWxsPSIjZjhmOGY4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDI4NSIgZD0ibTQ1MS4yOCA5MzEuNjFjLTM5Ljg1NCAwLTcyLjE2My04LjY2OS03Mi4xNjMtMTkuMzYxIDAtNS4wODUgNy4zMDM4LTkuNzExNCAxOS4yNTItMTMuMTY1IDAuMDQgMC4wMjg4IDAuMDgyNSAwLjA2MTUgMC4xMjI1IDAuMDkxMy0xMS44MjggMy40MzUxLTE5LjA1NCA4LjAyNzQtMTkuMDU0IDEzLjA3NCAwIDEwLjY0NSAzMi4xNjUgMTkuMjc1IDcxLjg0MiAxOS4yNzVzNzEuODQyLTguNjI5OSA3MS44NDItMTkuMjc1YzAtNS4wNDY0LTcuMjI2Mi05LjYzODYtMTkuMDU0LTEzLjA3NCAwLjA0LTAuMDI5OCAwLjA4MjUtMC4wNjI1IDAuMTIyNS0wLjA5MTMgMTEuOTQ5IDMuNDUzNiAxOS4yNTIgOC4wOCAxOS4yNTIgMTMuMTY1IDAgMTAuNjkyLTMyLjMwOSAxOS4zNjEtNzIuMTYzIDE5LjM2MSIgZmlsbD0iI2Y4ZjhmOCIvPgogICA8cGF0aCBpZD0icGF0aDQyODciIGQ9Im00NTEuMjggOTMxLjUzYy0zOS42NzcgMC03MS44NDItOC42Mjk5LTcxLjg0Mi0xOS4yNzUgMC01LjA0NjQgNy4yMjYtOS42Mzg2IDE5LjA1NC0xMy4wNzQgMC4wNDIgMC4wMzEyIDAuMDgwMSAwLjA1ODYgMC4xMjI2MyAwLjA5MTQtMTEuNzA4IDMuNDE2LTE4Ljg1NiA3Ljk3NS0xOC44NTYgMTIuOTgyIDAgMTAuNTk4IDMyLjAyMiAxOS4xODkgNzEuNTIyIDE5LjE4OXM3MS41MjItOC41OTEzIDcxLjUyMi0xOS4xODljMC01LjAwNzQtNy4xNDg4LTkuNTY2NC0xOC44NTYtMTIuOTgyIDAuMDQyNS0wLjAzMjcgMC4wOC0wLjA2MDEgMC4xMjI1LTAuMDkxNCAxMS44MjggMy40MzUxIDE5LjA1NCA4LjAyNzQgMTkuMDU0IDEzLjA3NCAwIDEwLjY0NS0zMi4xNjUgMTkuMjc1LTcxLjg0MiAxOS4yNzUiIGZpbGw9IiNmN2Y4ZjgiLz4KICAgPHBhdGggaWQ9InBhdGg0Mjg5IiBkPSJtNDUxLjI4IDkzMS40NGMtMzkuNSAwLTcxLjUyMi04LjU5MTMtNzEuNTIyLTE5LjE4OSAwLTUuMDA3NCA3LjE0ODQtOS41NjY0IDE4Ljg1Ni0xMi45ODIgMC4wNCAwLjAyODggMC4wODMgMC4wNjEgMC4xMjMgMC4wOTEzLTExLjU4OCAzLjM5NzUtMTguNjU4IDcuOTIyNC0xOC42NTggMTIuODkxIDAgMTAuNTUgMzEuODc4IDE5LjEwMyA3MS4yMDEgMTkuMTAzczcxLjItOC41NTI4IDcxLjItMTkuMTAzYzAtNC45Njg4LTcuMDctOS40OTM2LTE4LjY1OS0xMi44OTEgMC4wNC0wLjAzMDMgMC4wODM4LTAuMDYyNSAwLjEyMzc1LTAuMDkxMyAxMS43MDggMy40MTYgMTguODU2IDcuOTc1IDE4Ljg1NiAxMi45ODIgMCAxMC41OTgtMzIuMDIxIDE5LjE4OS03MS41MjIgMTkuMTg5IiBmaWxsPSIjZjdmN2Y3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDI5MSIgZD0ibTQ1MS4yOCA5MzEuMzVjLTM5LjMyMyAwLTcxLjIwMS04LjU1MjgtNzEuMjAxLTE5LjEwMyAwLTQuOTY4OCA3LjA2OTktOS40OTM2IDE4LjY1OC0xMi44OTEgMC4wNDI1IDAuMDMxMiAwLjA4MSAwLjA2MDEgMC4xMjM1IDAuMDkxNC0xMS40NyAzLjM3ODQtMTguNDYxIDcuODY5Ni0xOC40NjEgMTIuOCAwIDEwLjUwMiAzMS43MzQgMTkuMDE2IDcwLjg4IDE5LjAxNnM3MC44NzktOC41MTM2IDcwLjg3OS0xOS4wMTZjMC00LjkzMDEtNi45OTEyLTkuNDIxNC0xOC40NjEtMTIuOCAwLjA0MjUtMC4wMzEyIDAuMDgxMy0wLjA2MDEgMC4xMjM3NS0wLjA5MTQgMTEuNTg5IDMuMzk3NSAxOC42NTkgNy45MjI0IDE4LjY1OSAxMi44OTEgMCAxMC41NS0zMS44NzggMTkuMTAzLTcxLjIgMTkuMTAzIiBmaWxsPSIjZjZmN2Y3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDI5MyIgZD0ibTQ1MS4yOCA5MzEuMjdjLTM5LjE0NiAwLTcwLjg4LTguNTEzNi03MC44OC0xOS4wMTYgMC00LjkzMDEgNi45OTExLTkuNDIxNCAxOC40NjEtMTIuOCAwLjA0MDUgMC4wMjgyIDAuMDg0IDAuMDYxIDAuMTI0IDAuMDkwNy0xMS4zNDkgMy4zNTg5LTE4LjI2NCA3LjgxNzktMTguMjY0IDEyLjcwOSAwIDEwLjQ1NSAzMS41OSAxOC45MzEgNzAuNTU5IDE4LjkzMXM3MC41NTktOC40NzYgNzAuNTU5LTE4LjkzMWMwLTQuODkxMS02LjkxNjItOS4zNTAxLTE4LjI2NS0xMi43MDkgMC4wNC0wLjAyOTcgMC4wODM4LTAuMDYyNSAwLjEyMzc1LTAuMDkwNyAxMS40NyAzLjM3ODQgMTguNDYxIDcuODY5NiAxOC40NjEgMTIuOCAwIDEwLjUwMi0zMS43MzQgMTkuMDE2LTcwLjg3OSAxOS4wMTYiIGZpbGw9IiNmNmY2ZjciLz4KICAgPHBhdGggaWQ9InBhdGg0Mjk1IiBkPSJtNDUxLjI4IDkzMS4xOGMtMzguOTY5IDAtNzAuNTU5LTguNDc2LTcwLjU1OS0xOC45MzEgMC00Ljg5MTEgNi45MTUtOS4zNTAxIDE4LjI2NC0xMi43MDkgMC4wNDI1IDAuMDMxMiAwLjA4MjYgMC4wNjAxIDAuMTI1IDAuMDkxNC0xMS4yMzIgMy4zNDAyLTE4LjA2OCA3Ljc2NTEtMTguMDY4IDEyLjYxOCAwIDEwLjQwOCAzMS40NDcgMTguODQ1IDcwLjIzOCAxOC44NDUgMzguNzkyIDAgNzAuMjM4LTguNDM3NSA3MC4yMzgtMTguODQ1IDAtNC44NTI1LTYuODM2Mi05LjI3NzQtMTguMDY5LTEyLjYxOCAwLjA0MzgtMC4wMzEyIDAuMDgyNS0wLjA2MDEgMC4xMjUtMC4wOTE0IDExLjM0OSAzLjM1ODkgMTguMjY1IDcuODE3OSAxOC4yNjUgMTIuNzA5IDAgMTAuNDU1LTMxLjU5IDE4LjkzMS03MC41NTkgMTguOTMxIiBmaWxsPSIjZjZmNmY2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDI5NyIgZD0ibTQ1MS4yOCA5MzEuMWMtMzguNzkyIDAtNzAuMjM4LTguNDM3NS03MC4yMzgtMTguODQ1IDAtNC44NTI1IDYuODM2NC05LjI3NzQgMTguMDY4LTEyLjYxOCAwLjA0MDEgMC4wMjg3IDAuMDg0NSAwLjA2MTUgMC4xMjUgMC4wOTEyLTExLjExMyAzLjMyMTMtMTcuODcyIDcuNzExNC0xNy44NzIgMTIuNTI2IDAgMTAuMzYgMzEuMzAzIDE4Ljc1OSA2OS45MTggMTguNzU5IDM4LjYxNCAwIDY5LjkxOC04LjM5ODkgNjkuOTE4LTE4Ljc1OSAwLTQuODE1LTYuNzYtOS4yMDUxLTE3Ljg3NC0xMi41MjYgMC4wNDEyLTAuMDI5NyAwLjA4NS0wLjA2MjUgMC4xMjUtMC4wOTEyIDExLjIzMiAzLjM0MDIgMTguMDY5IDcuNzY1MSAxOC4wNjkgMTIuNjE4IDAgMTAuNDA4LTMxLjQ0NiAxOC44NDUtNzAuMjM4IDE4Ljg0NSIgZmlsbD0iI2Y1ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDQyOTkiIGQ9Im00NTEuMjggOTMxLjAxYy0zOC42MTQgMC02OS45MTgtOC4zOTg5LTY5LjkxOC0xOC43NTkgMC00LjgxNSA2Ljc1OTItOS4yMDUxIDE3Ljg3Mi0xMi41MjYgMC4wNDI1IDAuMDMxMiAwLjA4MyAwLjA2MDEgMC4xMjU1IDAuMDkxNC0xMC45OTUgMy4zMDI2LTE3LjY3NyA3LjY1ODYtMTcuNjc3IDEyLjQzNSAwIDEwLjMxMiAzMS4xNTkgMTguNjcyIDY5LjU5NyAxOC42NzIgMzguNDM3IDAgNjkuNTk3LTguMzU5OSA2OS41OTctMTguNjcyIDAtNC43NzY0LTYuNjgyNS05LjEzMjQtMTcuNjc4LTEyLjQzNSAwLjA0MjUtMC4wMzEyIDAuMDgyNS0wLjA2MDEgMC4xMjUtMC4wOTE0IDExLjExNCAzLjMyMTMgMTcuODc0IDcuNzExNCAxNy44NzQgMTIuNTI2IDAgMTAuMzYtMzEuMzA0IDE4Ljc1OS02OS45MTggMTguNzU5IiBmaWxsPSIjZjVmNWY1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDMwMSIgZD0ibTQ1MS4yOCA5MzAuOTJjLTM4LjQzOCAwLTY5LjU5Ny04LjM1OTktNjkuNTk3LTE4LjY3MiAwLTQuNzc2NCA2LjY4MjYtOS4xMzI0IDE3LjY3Ny0xMi40MzUgMC4wNDI1IDAuMDMxMiAwLjA4MzUgMC4wNiAwLjEyNiAwLjA5MTMtMTAuODc3IDMuMjgyOC0xNy40ODIgNy42MDY1LTE3LjQ4MiAxMi4zNDQgMCAxMC4yNjUgMzEuMDE2IDE4LjU4NiA2OS4yNzYgMTguNTg2czY5LjI3Ni04LjMyMTMgNjkuMjc2LTE4LjU4NmMwLTQuNzM3Mi02LjYwNS05LjA2MS0xNy40ODItMTIuMzQ0IDAuMDQyNS0wLjAzMTIgMC4wODM4LTAuMDYgMC4xMjYyNS0wLjA5MTMgMTAuOTk1IDMuMzAyNiAxNy42NzggNy42NTg2IDE3LjY3OCAxMi40MzUgMCAxMC4zMTItMzEuMTYgMTguNjcyLTY5LjU5NyAxOC42NzIiIGZpbGw9IiNmNWY1ZjUiLz4KICAgPHBhdGggaWQ9InBhdGg0MzAzIiBkPSJtNDUxLjI4IDkzMC44NGMtMzguMjYgMC02OS4yNzYtOC4zMjEzLTY5LjI3Ni0xOC41ODYgMC00LjczNzIgNi42MDU1LTkuMDYxIDE3LjQ4Mi0xMi4zNDQgMC4wNDMgMC4wMzEyIDAuMDg0IDAuMDYxIDAuMTI2NSAwLjA5MTItMTAuNzU4IDMuMjYzOC0xNy4yODkgNy41NTM4LTE3LjI4OSAxMi4yNTIgMCAxMC4yMTcgMzAuODczIDE4LjUgNjguOTU2IDE4LjVzNjguOTU2LTguMjgyOCA2OC45NTYtMTguNWMwLTQuNjk4OC02LjUzMTItOC45ODg4LTE3LjI4OS0xMi4yNTIgMC4wNDI1LTAuMDMwMyAwLjA4MzgtMC4wNiAwLjEyNjI1LTAuMDkxMiAxMC44NzggMy4yODI4IDE3LjQ4MiA3LjYwNjUgMTcuNDgyIDEyLjM0NCAwIDEwLjI2NS0zMS4wMTUgMTguNTg2LTY5LjI3NiAxOC41ODYiIGZpbGw9IiNmNGY0ZjQiLz4KICAgPHBhdGggaWQ9InBhdGg0MzA1IiBkPSJtNDUxLjI4IDkzMC43NWMtMzguMDgzIDAtNjguOTU2LTguMjgyOC02OC45NTYtMTguNSAwLTQuNjk4OCA2LjUzMDgtOC45ODg4IDE3LjI4OS0xMi4yNTIgMC4wNDA1IDAuMDI5OSAwLjA4NjQgMC4wNjI1IDAuMTI2ODcgMC4wOTE0LTEwLjY0MiAzLjI0MzYtMTcuMDk1IDcuNS0xNy4wOTUgMTIuMTYxIDAgMTAuMTcgMzAuNzI5IDE4LjQxNSA2OC42MzUgMTguNDE1czY4LjYzNC04LjI0NTIgNjguNjM0LTE4LjQxNWMwLTQuNjYxMS02LjQ1MjUtOC45MTc1LTE3LjA5NS0xMi4xNjEgMC4wNDEyLTAuMDI4OSAwLjA4NzUtMC4wNjE1IDAuMTI3NS0wLjA5MTQgMTAuNzU4IDMuMjYzOCAxNy4yODkgNy41NTM4IDE3LjI4OSAxMi4yNTIgMCAxMC4yMTctMzAuODcyIDE4LjUtNjguOTU2IDE4LjUiIGZpbGw9IiNmNGY0ZjQiLz4KICAgPHBhdGggaWQ9InBhdGg0MzA3IiBkPSJtNDUxLjI4IDkzMC42N2MtMzcuOTA2IDAtNjguNjM1LTguMjQ1Mi02OC42MzUtMTguNDE1IDAtNC42NjExIDYuNDUzMS04LjkxNzUgMTcuMDk1LTEyLjE2MSAwLjA0MyAwLjAzMTIgMC4wODUgMC4wNjEgMC4xMjggMC4wOTIyLTEwLjUyNCAzLjIyMzctMTYuOTAyIDcuNDQ2NC0xNi45MDIgMTIuMDY5IDAgMTAuMTIyIDMwLjU4NiAxOC4zMjkgNjguMzE0IDE4LjMyOXM2OC4zMTQtOC4yMDYxIDY4LjMxNC0xOC4zMjljMC00LjYyMjUtNi4zNzc1LTguODQ1Mi0xNi45MDItMTIuMDY5IDAuMDQzNy0wLjAzMTIgMC4wODUtMC4wNjEgMC4xMjc1LTAuMDkyMiAxMC42NDIgMy4yNDM2IDE3LjA5NSA3LjUgMTcuMDk1IDEyLjE2MSAwIDEwLjE3LTMwLjcyOSAxOC40MTUtNjguNjM0IDE4LjQxNSIgZmlsbD0iI2YzZjNmMyIvPgogICA8cGF0aCBpZD0icGF0aDQzMDkiIGQ9Im00NTEuMjggOTMwLjU4Yy0zNy43MjggMC02OC4zMTQtOC4yMDYxLTY4LjMxNC0xOC4zMjkgMC00LjYyMjUgNi4zNzc1LTguODQ1MiAxNi45MDItMTIuMDY5IDAuMDQzIDAuMDMwMyAwLjA4NSAwLjA2MDEgMC4xMjc4OCAwLjA5MTQtMTAuNDA3IDMuMjA1LTE2LjcwOSA3LjM5MzUtMTYuNzA5IDExLjk3OCAwIDEwLjA3NSAzMC40NDIgMTguMjQzIDY3Ljk5MyAxOC4yNDMgMzcuNTUyIDAgNjcuOTkzLTguMTY3NCA2Ny45OTMtMTguMjQzIDAtNC41ODQtNi4zMDEyLTguNzcyNS0xNi43MDktMTEuOTc4IDAuMDQyNS0wLjAzMTIgMC4wODUtMC4wNjExIDAuMTI3NS0wLjA5MTQgMTAuNTI1IDMuMjIzNiAxNi45MDIgNy40NDY0IDE2LjkwMiAxMi4wNjkgMCAxMC4xMjItMzAuNTg2IDE4LjMyOS02OC4zMTQgMTguMzI5IiBmaWxsPSIjZjNmM2YyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMxMSIgZD0ibTQ1MS4yOCA5MzAuNWMtMzcuNTUyIDAtNjcuOTkzLTguMTY3NC02Ny45OTMtMTguMjQzIDAtNC41ODQgNi4zMDE5LTguNzcyNSAxNi43MDktMTEuOTc4IDAuMDQzIDAuMDI5OCAwLjA4NTUgMC4wNjE1IDAuMTI4NSAwLjA5MTMtMTAuMjg5IDMuMTg1MS0xNi41MTcgNy4zNDE0LTE2LjUxNyAxMS44ODYgMCAxMC4wMjcgMzAuMjk4IDE4LjE1NiA2Ny42NzIgMTguMTU2czY3LjY3Mi04LjEyODkgNjcuNjcyLTE4LjE1NmMwLTQuNTQ2NC02LjIyNS04LjcwMTEtMTYuNTE2LTExLjg4NiAwLjA0MjUtMC4wMjk4IDAuMDg1LTAuMDYxNSAwLjEyODc1LTAuMDkxMyAxMC40MDggMy4yMDUgMTYuNzA5IDcuMzkzNSAxNi43MDkgMTEuOTc4IDAgMTAuMDc1LTMwLjQ0MSAxOC4yNDMtNjcuOTkzIDE4LjI0MyIgZmlsbD0iI2YyZjJmMiIvPgogICA8cGF0aCBpZD0icGF0aDQzMTMiIGQ9Im00NTEuMjggOTMwLjQxYy0zNy4zNzQgMC02Ny42NzItOC4xMjg5LTY3LjY3Mi0xOC4xNTYgMC00LjU0NDkgNi4yMjgtOC43MDExIDE2LjUxNy0xMS44ODYgMC4wNDI5IDAuMDI5OCAwLjA4NTkgMC4wNjEgMC4xMjkzNyAwLjA5MTQtMTAuMTc0IDMuMTY1LTE2LjMyNSA3LjI4NzUtMTYuMzI1IDExLjc5NSAwIDkuOTggMzAuMTU0IDE4LjA3IDY3LjM1MiAxOC4wNyAzNy4xOTcgMCA2Ny4zNTItOC4wODk4IDY3LjM1Mi0xOC4wNyAwLTQuNTA3NC02LjE1MjUtOC42Mjk5LTE2LjMyNS0xMS43OTUgMC4wNDI1LTAuMDMwNCAwLjA4NjItMC4wNjE2IDAuMTI4NzUtMC4wOTE0IDEwLjI5MSAzLjE4NTEgMTYuNTE2IDcuMzM5OSAxNi41MTYgMTEuODg2IDAgMTAuMDI3LTMwLjI5OCAxOC4xNTYtNjcuNjcyIDE4LjE1NiIgZmlsbD0iI2YyZjFmMSIvPgogICA8cGF0aCBpZD0icGF0aDQzMTUiIGQ9Im00NTEuMjggOTMwLjMyYy0zNy4xOTcgMC02Ny4zNTItOC4wODk4LTY3LjM1Mi0xOC4wNyAwLTQuNTA3NCA2LjE1MTQtOC42Mjk5IDE2LjMyNS0xMS43OTUgMC4wNDMgMC4wMzEyIDAuMDg2NCAwLjA2MSAwLjEyOTM4IDAuMDkyMy0xMC4wNTYgMy4xNDUtMTYuMTM0IDcuMjMzOS0xNi4xMzQgMTEuNzAzIDAgOS45MzI2IDMwLjAxMSAxNy45ODQgNjcuMDMxIDE3Ljk4NHM2Ny4wMy04LjA1MTMgNjcuMDMtMTcuOTg0YzAtNC40Njg4LTYuMDc3NS04LjU1NzYtMTYuMTM0LTExLjcwMyAwLjA0MjUtMC4wMzEyIDAuMDg2My0wLjA2MSAwLjEzLTAuMDkyMyAxMC4xNzIgMy4xNjUgMTYuMzI1IDcuMjg3NSAxNi4zMjUgMTEuNzk1IDAgOS45OC0zMC4xNTUgMTguMDctNjcuMzUyIDE4LjA3IiBmaWxsPSIjZjFmMWYxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMxNyIgZD0ibTQ1MS4yOCA5MzAuMjRjLTM3LjAyIDAtNjcuMDMxLTguMDUxMy02Ny4wMzEtMTcuOTg0IDAtNC40Njg4IDYuMDc3Ni04LjU1NzYgMTYuMTM0LTExLjcwMyAwLjA0MyAwLjAzMDMgMC4wODY5IDAuMDYxNSAwLjEyOTg3IDAuMDkxMy05LjkzOTkgMy4xMjUtMTUuOTQzIDcuMTgwMi0xNS45NDMgMTEuNjExIDAgOS44ODQ4IDI5Ljg2NyAxNy44OTcgNjYuNzEgMTcuODk3czY2LjcxLTguMDEyNyA2Ni43MS0xNy44OTdjMC00LjQzMTEtNi4wMDM4LTguNDg2NC0xNS45NDQtMTEuNjExIDAuMDQzOC0wLjAyOTcgMC4wODc1LTAuMDYxIDAuMTMtMC4wOTEzIDEwLjA1NiAzLjE0NSAxNi4xMzQgNy4yMzM5IDE2LjEzNCAxMS43MDMgMCA5LjkzMjYtMzAuMDEgMTcuOTg0LTY3LjAzIDE3Ljk4NCIgZmlsbD0iI2YwZjBmMCIvPgogICA8cGF0aCBpZD0icGF0aDQzMTkiIGQ9Im00NTEuMjggOTMwLjE1Yy0zNi44NDMgMC02Ni43MS04LjAxMjctNjYuNzEtMTcuODk3IDAtNC40MzExIDYuMDAzLTguNDg2NCAxNS45NDMtMTEuNjExIDAuMDQzNSAwLjAzMDQgMC4wODc0IDAuMDYxNiAwLjEzMDg4IDAuMDkxNC05LjgyNTIgMy4xMDY0LTE1Ljc1MyA3LjEyNzQtMTUuNzUzIDExLjUyIDAgOS44Mzc0IDI5LjcyMyAxNy44MTIgNjYuMzg5IDE3LjgxMnM2Ni4zODktNy45NzUxIDY2LjM4OS0xNy44MTJjMC00LjM5MjYtNS45Mjg4LTguNDEzNi0xNS43NTQtMTEuNTIgMC4wNDM3LTAuMDI5OCAwLjA4ODgtMC4wNjEgMC4xMzEyNS0wLjA5MTQgOS45NCAzLjEyNSAxNS45NDQgNy4xODAyIDE1Ljk0NCAxMS42MTEgMCA5Ljg4NDgtMjkuODY4IDE3Ljg5Ny02Ni43MSAxNy44OTciIGZpbGw9IiNmMGYwZWYiLz4KICAgPHBhdGggaWQ9InBhdGg0MzIxIiBkPSJtNDUxLjI4IDkzMC4wNmMtMzYuNjY2IDAtNjYuMzg5LTcuOTc1MS02Ni4zODktMTcuODEyIDAtNC4zOTI2IDUuOTI3Ni04LjQxMzYgMTUuNzUzLTExLjUyIDAuMDQ1NCAwLjAzMTIgMC4wODU1IDAuMDYgMC4xMzEzNyAwLjA5MTItOS43MTA1IDMuMDg2NS0xNS41NjQgNy4wNzM4LTE1LjU2NCAxMS40MjkgMCA5Ljc5IDI5LjU4IDE3LjcyNiA2Ni4wNjggMTcuNzI2czY2LjA2OC03LjkzNjEgNjYuMDY4LTE3LjcyNmMwLTQuMzU1LTUuODUyNS04LjM0MjItMTUuNTY0LTExLjQyOSAwLjA0NjItMC4wMzEyIDAuMDg2My0wLjA2IDAuMTMxMjUtMC4wOTEyIDkuODI1IDMuMTA2NCAxNS43NTQgNy4xMjc0IDE1Ljc1NCAxMS41MiAwIDkuODM3NC0yOS43MjQgMTcuODEyLTY2LjM4OSAxNy44MTIiIGZpbGw9IiNlZmVmZWYiLz4KICAgPHBhdGggaWQ9InBhdGg0MzIzIiBkPSJtNDUxLjI4IDkyOS45OGMtMzYuNDg5IDAtNjYuMDY4LTcuOTM2MS02Ni4wNjgtMTcuNzI2IDAtNC4zNTUgNS44NTMtOC4zNDIyIDE1LjU2NC0xMS40MjkgMC4wNDI5IDAuMDI5OSAwLjA4ODQgMC4wNjI1IDAuMTMxMzggMC4wOTI0LTkuNTk0MiAzLjA2NDktMTUuMzc1IDcuMDItMTUuMzc1IDExLjMzNiAwIDkuNzQyNiAyOS40MzcgMTcuNjQgNjUuNzQ4IDE3LjY0IDM2LjMxMiAwIDY1Ljc0OC03Ljg5NzUgNjUuNzQ4LTE3LjY0IDAtNC4zMTY0LTUuNzgtOC4yNzE1LTE1LjM3NS0xMS4zMzYgMC4wNDM3LTAuMDI5OSAwLjA4ODctMC4wNjI1IDAuMTMxMjUtMC4wOTI0IDkuNzExMiAzLjA4NjUgMTUuNTY0IDcuMDczOCAxNS41NjQgMTEuNDI5IDAgOS43OS0yOS41OCAxNy43MjYtNjYuMDY4IDE3LjcyNiIgZmlsbD0iI2VlZSIvPgogICA8cGF0aCBpZD0icGF0aDQzMjUiIGQ9Im00NTEuMjggOTI5Ljg5Yy0zNi4zMTIgMC02NS43NDgtNy44OTc1LTY1Ljc0OC0xNy42NCAwLTQuMzE2NCA1Ljc4MDQtOC4yNzE1IDE1LjM3NS0xMS4zMzYgMC4wNDM0IDAuMDMwMiAwLjA4ODggMC4wNjE1IDAuMTMyMjUgMC4wOTEyLTkuNDgxNCAzLjA0NjQtMTUuMTg2IDYuOTY2NC0xNS4xODYgMTEuMjQ1IDAgOS42OTQ5IDI5LjI5MiAxNy41NTQgNjUuNDI3IDE3LjU1NCAzNi4xMzQgMCA2NS40MjctNy44NTg4IDY1LjQyNy0xNy41NTQgMC00LjI3ODgtNS43MDM4LTguMTk4OC0xNS4xODYtMTEuMjQ1IDAuMDQzOC0wLjAyOTcgMC4wODg4LTAuMDYxIDAuMTMyNS0wLjA5MTIgOS41OTUgMy4wNjQ5IDE1LjM3NSA3LjAyIDE1LjM3NSAxMS4zMzYgMCA5Ljc0MjYtMjkuNDM2IDE3LjY0LTY1Ljc0OCAxNy42NCIgZmlsbD0iI2VlZWVlZCIvPgogICA8cGF0aCBpZD0icGF0aDQzMjciIGQ9Im00NTEuMjggOTI5LjgxYy0zNi4xMzQgMC02NS40MjctNy44NTg4LTY1LjQyNy0xNy41NTQgMC00LjI3ODggNS43MDQxLTguMTk4OCAxNS4xODYtMTEuMjQ1IDAuMDQ1NCAwLjAzMTIgMC4wODY5IDAuMDYwMSAwLjEzMjg3IDAuMDkxNC05LjM2NzIgMy4wMjYyLTE0Ljk5OCA2LjkxMzUtMTQuOTk4IDExLjE1NCAwIDkuNjQ3NSAyOS4xNDggMTcuNDY3IDY1LjEwNiAxNy40NjcgMzUuOTU3IDAgNjUuMTA2LTcuODE5OCA2NS4xMDYtMTcuNDY3IDAtNC4yNDAyLTUuNjMtOC4xMjc1LTE0Ljk5OC0xMS4xNTQgMC4wNDYzLTAuMDMxMiAwLjA4NzUtMC4wNjAxIDAuMTMyNS0wLjA5MTQgOS40ODI1IDMuMDQ2NCAxNS4xODYgNi45NjY0IDE1LjE4NiAxMS4yNDUgMCA5LjY5NDktMjkuMjkyIDE3LjU1NC02NS40MjcgMTcuNTU0IiBmaWxsPSIjZWRlZGVjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMyOSIgZD0ibTQ1MS4yOCA5MjkuNzJjLTM1Ljk1OCAwLTY1LjEwNi03LjgxOTgtNjUuMTA2LTE3LjQ2NyAwLTQuMjQwMiA1LjYzMDQtOC4xMjc1IDE0Ljk5OC0xMS4xNTQgMC4wNDM0IDAuMDMwMyAwLjA4OTcgMC4wNjI1IDAuMTMzMjUgMC4wOTIzLTkuMjUxIDMuMDA1NC0xNC44MSA2Ljg1ODktMTQuODEgMTEuMDYyIDAgOS42MDAxIDI5LjAwNiAxNy4zODEgNjQuNzg2IDE3LjM4MXM2NC43ODYtNy43ODEyIDY0Ljc4Ni0xNy4zODFjMC00LjIwMjYtNS41Ni04LjA1NjEtMTQuODExLTExLjA2MiAwLjA0MzctMC4wMjk4IDAuMDktMC4wNjIgMC4xMzM3NS0wLjA5MjMgOS4zNjc1IDMuMDI2MiAxNC45OTggNi45MTM1IDE0Ljk5OCAxMS4xNTQgMCA5LjY0NzUtMjkuMTQ4IDE3LjQ2Ny02NS4xMDYgMTcuNDY3IiBmaWxsPSIjZWRlZGVjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMzMSIgZD0ibTQ1MS4yOCA5MjkuNjNjLTM1Ljc4IDAtNjQuNzg2LTcuNzgxMi02NC43ODYtMTcuMzgxIDAtNC4yMDI2IDUuNTU5NS04LjA1NjEgMTQuODEtMTEuMDYyIDAuMDQ1OSAwLjAzMTcgMC4wODg0IDAuMDYgMC4xMzM3NSAwLjA5MTItOS4xMzgxIDIuOTg1NC0xNC42MjQgNi44MDY2LTE0LjYyNCAxMC45NyAwIDkuNTUyOCAyOC44NjIgMTcuMjk1IDY0LjQ2NSAxNy4yOTUgMzUuNjAyIDAgNjQuNDY0LTcuNzQyMiA2NC40NjQtMTcuMjk1IDAtNC4xNjM2LTUuNDg1LTcuOTg0OS0xNC42MjQtMTAuOTcgMC4wNDYyLTAuMDMxMiAwLjA4ODctMC4wNTk1IDAuMTMzNzUtMC4wOTEyIDkuMjUxMiAzLjAwNTQgMTQuODExIDYuODU4OSAxNC44MTEgMTEuMDYyIDAgOS42MDAxLTI5LjAwNiAxNy4zODEtNjQuNzg2IDE3LjM4MSIgZmlsbD0iI2VjZWNlYiIvPgogICA8cGF0aCBpZD0icGF0aDQzMzMiIGQ9Im00NTEuMjggOTI5LjU1Yy0zNS42MDMgMC02NC40NjUtNy43NDIyLTY0LjQ2NS0xNy4yOTUgMC00LjE2MzYgNS40ODU0LTcuOTg0OSAxNC42MjQtMTAuOTcgMC4wNDQgMC4wMzAzIDAuMDkxNCAwLjA2MjUgMC4xMzQ3NSAwLjA5MjgtOS4wMjQ0IDIuOTYzOS0xNC40MzggNi43NTEtMTQuNDM4IDEwLjg3OCAwIDkuNTA0OSAyOC43MTggMTcuMjEgNjQuMTQ0IDE3LjIxczY0LjE0My03LjcwNTEgNjQuMTQzLTE3LjIxYzAtNC4xMjY1LTUuNDEyNS03LjkxMzYtMTQuNDM2LTEwLjg3OCAwLjA0MzgtMC4wMzAzIDAuMDktMC4wNjI1IDAuMTMzNzUtMC4wOTI4IDkuMTM4OCAyLjk4NTQgMTQuNjI0IDYuODA2NiAxNC42MjQgMTAuOTcgMCA5LjU1MjgtMjguODYyIDE3LjI5NS02NC40NjQgMTcuMjk1IiBmaWxsPSIjZWNlYmVhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMzNSIgZD0ibTQ1MS4yOCA5MjkuNDZjLTM1LjQyNiAwLTY0LjE0NC03LjcwNTEtNjQuMTQ0LTE3LjIxIDAtNC4xMjY1IDUuNDEzMS03LjkxMzYgMTQuNDM4LTEwLjg3OCAwLjA0NiAwLjAzMTIgMC4wODg5IDAuMDYwMSAwLjEzNDg4IDAuMDkxNC04LjkxMTEgMi45NDM5LTE0LjI1MiA2LjY5NzItMTQuMjUyIDEwLjc4NiAwIDkuNDU3NSAyOC41NzUgMTcuMTI0IDYzLjgyMyAxNy4xMjRzNjMuODIzLTcuNjY2IDYzLjgyMy0xNy4xMjRjMC00LjA4ODktNS4zNC03Ljg0MjItMTQuMjUxLTEwLjc4NiAwLjA0NS0wLjAzMTIgMC4wODg4LTAuMDYwMSAwLjEzNS0wLjA5MTQgOS4wMjM4IDIuOTYzOSAxNC40MzYgNi43NTEgMTQuNDM2IDEwLjg3OCAwIDkuNTA0OS0yOC43MTcgMTcuMjEtNjQuMTQzIDE3LjIxIiBmaWxsPSIjZWJlYmVhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDMzNyIgZD0ibTQ1MS4yOCA5MjkuMzhjLTM1LjI0OCAwLTYzLjgyMy03LjY2Ni02My44MjMtMTcuMTI0IDAtNC4wODg5IDUuMzQwNC03Ljg0MjIgMTQuMjUyLTEwLjc4NiAwLjA0MzkgMC4wMjk3IDAuMDkyMiAwLjA2MjUgMC4xMzU3NSAwLjA5MTItOC43OTg5IDIuOTI0OS0xNC4wNjYgNi42NDUxLTE0LjA2NiAxMC42OTUgMCA5LjQxMDEgMjguNDMxIDE3LjAzOCA2My41MDIgMTcuMDM4czYzLjUwMi03LjYyNzUgNjMuNTAyLTE3LjAzOGMwLTQuMDQ5OC01LjI2NzUtNy43Ny0xNC4wNjYtMTAuNjk1IDAuMDQzNy0wLjAyODcgMC4wOTI1LTAuMDYxNSAwLjEzNjI1LTAuMDkxMiA4LjkxMTIgMi45NDM5IDE0LjI1MSA2LjY5NzIgMTQuMjUxIDEwLjc4NiAwIDkuNDU3NS0yOC41NzQgMTcuMTI0LTYzLjgyMyAxNy4xMjQiIGZpbGw9IiNlYWVhZTkiLz4KICAgPHBhdGggaWQ9InBhdGg0MzM5IiBkPSJtNDUxLjI4IDkyOS4yOWMtMzUuMDcxIDAtNjMuNTAyLTcuNjI3NS02My41MDItMTcuMDM4IDAtNC4wNDk4IDUuMjY3Ni03Ljc3IDE0LjA2Ni0xMC42OTUgMC4wNDY0IDAuMDMxMiAwLjA5MDMgMC4wNjExIDAuMTM2MTIgMC4wOTI0LTguNjg1IDIuOTAzOC0xMy44ODIgNi41ODk4LTEzLjg4MiAxMC42MDIgMCA5LjM2MjIgMjguMjg4IDE2Ljk1MSA2My4xODIgMTYuOTUxczYzLjE4Mi03LjU4ODkgNjMuMTgyLTE2Ljk1MWMwLTQuMDEyOC01LjE5NzUtNy42OTg4LTEzLjg4Mi0xMC42MDIgMC4wNDYzLTAuMDMxMiAwLjA5LTAuMDYxMSAwLjEzNjI1LTAuMDkyNCA4Ljc5ODggMi45MjQ5IDE0LjA2NiA2LjY0NTEgMTQuMDY2IDEwLjY5NSAwIDkuNDEwMS0yOC40MyAxNy4wMzgtNjMuNTAyIDE3LjAzOCIgZmlsbD0iI2VhZWFlOCIvPgogICA8cGF0aCBpZD0icGF0aDQzNDEiIGQ9Im00NTEuMjggOTI5LjJjLTM0Ljg5NCAwLTYzLjE4Mi03LjU4ODktNjMuMTgyLTE2Ljk1MSAwLTQuMDEyOCA1LjE5NjgtNy42OTg4IDEzLjg4Mi0xMC42MDIgMC4wNDY0IDAuMDMxMiAwLjA5MDkgMC4wNjEgMC4xMzY3NSAwLjA5MTMtOC41NzIyIDIuODgzOC0xMy42OTggNi41Mzc2LTEzLjY5OCAxMC41MTEgMCA5LjMxNSAyOC4xNDQgMTYuODY1IDYyLjg2MSAxNi44NjVzNjIuODYtNy41NDk4IDYyLjg2LTE2Ljg2NWMwLTMuOTczNi01LjEyNS03LjYyNzUtMTMuNjk4LTEwLjUxMSAwLjA0NjItMC4wMzAzIDAuMDktMC4wNiAwLjEzNjI1LTAuMDkxMyA4LjY4NSAyLjkwMzggMTMuODgyIDYuNTg5OCAxMy44ODIgMTAuNjAyIDAgOS4zNjIyLTI4LjI4OCAxNi45NTEtNjMuMTgyIDE2Ljk1MSIgZmlsbD0iI2U5ZTllOCIvPgogICA8cGF0aCBpZD0icGF0aDQzNDMiIGQ9Im00NTEuMjggOTI5LjEyYy0zNC43MTcgMC02Mi44NjEtNy41NDk4LTYyLjg2MS0xNi44NjUgMC0zLjk3MzYgNS4xMjU1LTcuNjI3NSAxMy42OTgtMTAuNTExIDAuMDQ2NCAwLjAzMTIgMC4wOTE0IDAuMDYxIDAuMTM3NzUgMC4wOTIzLTguNDYxIDIuODYyOS0xMy41MTUgNi40ODMtMTMuNTE1IDEwLjQxOSAwIDkuMjY3NiAyOCAxNi43NzkgNjIuNTQgMTYuNzc5czYyLjUzOS03LjUxMTIgNjIuNTM5LTE2Ljc3OWMwLTMuOTM2LTUuMDUzOC03LjU1NjEtMTMuNTE0LTEwLjQxOSAwLjA0NjItMC4wMzEyIDAuMDkxMi0wLjA2MSAwLjEzNzUtMC4wOTIzIDguNTcyNSAyLjg4MzggMTMuNjk4IDYuNTM3NiAxMy42OTggMTAuNTExIDAgOS4zMTUtMjguMTQ0IDE2Ljg2NS02Mi44NiAxNi44NjUiIGZpbGw9IiNlOWU4ZTciLz4KICAgPHBhdGggaWQ9InBhdGg0MzQ1IiBkPSJtNDUxLjI4IDkyOS4wM2MtMzQuNTQgMC02Mi41NC03LjUxMTItNjIuNTQtMTYuNzc5IDAtMy45MzYgNS4wNTM4LTcuNTU2MSAxMy41MTUtMTAuNDE5IDAuMDQ1OSAwLjAzMTIgMC4wOTE3IDAuMDYxNiAwLjEzNzYzIDAuMDkyOS04LjM0OTYgMi44NDEzLTEzLjMzMiA2LjQyNzMtMTMuMzMyIDEwLjMyNiAwIDkuMjIwMiAyNy44NTYgMTYuNjk0IDYyLjIxOSAxNi42OTQgMzQuMzYyIDAgNjIuMjE5LTcuNDczNiA2Mi4yMTktMTYuNjk0IDAtMy44OTg5LTQuOTgyNS03LjQ4NDktMTMuMzMyLTEwLjMyNiAwLjA0NjMtMC4wMzEyIDAuMDkyNS0wLjA2MTYgMC4xMzg3NS0wLjA5MjkgOC40NiAyLjg2MjkgMTMuNTE0IDYuNDgzIDEzLjUxNCAxMC40MTkgMCA5LjI2NzYtMjcuOTk5IDE2Ljc3OS02Mi41MzkgMTYuNzc5IiBmaWxsPSIjZThlOGU2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM0NyIgZD0ibTQ1MS4yOCA5MjguOTVjLTM0LjM2MyAwLTYyLjIxOS03LjQ3MzYtNjIuMjE5LTE2LjY5NCAwLTMuODk4OSA0Ljk4MTktNy40ODQ5IDEzLjMzMi0xMC4zMjYgMC4wNDY0IDAuMDI5OCAwLjA5MjQgMC4wNiAwLjEzODc1IDAuMDkxMy04LjIzNzQgMi44MTk5LTEzLjE1IDYuMzczNS0xMy4xNSAxMC4yMzUgMCA5LjE3MjQgMjcuNzEzIDE2LjYwNyA2MS44OTggMTYuNjA3IDM0LjE4NiAwIDYxLjg5OC03LjQzNTEgNjEuODk4LTE2LjYwNyAwLTMuODYxNC00LjkxMTItNy40MTUtMTMuMTQ5LTEwLjIzNSAwLjA0NjItMC4wMzEyIDAuMDkxMy0wLjA2MTUgMC4xMzc1LTAuMDkxMyA4LjM1IDIuODQxMyAxMy4zMzIgNi40MjczIDEzLjMzMiAxMC4zMjYgMCA5LjIyMDItMjcuODU3IDE2LjY5NC02Mi4yMTkgMTYuNjk0IiBmaWxsPSIjZTdlN2U2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM0OSIgZD0ibTQ1MS4yOCA5MjguODZjLTM0LjE4NiAwLTYxLjg5OC03LjQzNTEtNjEuODk4LTE2LjYwNyAwLTMuODYxNCA0LjkxMjEtNy40MTUgMTMuMTUtMTAuMjM1IDAuMDQ2NCAwLjAzMTIgMC4wOTI4IDAuMDYxIDAuMTM5MTIgMC4wOTIyLTguMTI4IDIuNzk4OS0xMi45NjggNi4zMTg5LTEyLjk2OCAxMC4xNDMgMCA5LjEyMzUgMjcuNTY5IDE2LjUyMSA2MS41NzggMTYuNTIxIDM0LjAwOCAwIDYxLjU3OC03LjM5OCA2MS41NzgtMTYuNTIxIDAtMy44MjM4LTQuODQtNy4zNDM4LTEyLjk2OS0xMC4xNDMgMC4wNDYzLTAuMDMxMiAwLjA5MzctMC4wNjEgMC4xNC0wLjA5MjIgOC4yMzc1IDIuODE5OSAxMy4xNDkgNi4zNzM1IDEzLjE0OSAxMC4yMzUgMCA5LjE3MjQtMjcuNzEyIDE2LjYwNy02MS44OTggMTYuNjA3IiBmaWxsPSIjZTdlNmU1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM1MSIgZD0ibTQ1MS4yOCA5MjguNzdjLTM0LjAwOSAwLTYxLjU3OC03LjM5OC02MS41NzgtMTYuNTIxIDAtMy44MjM4IDQuODM5Ny03LjM0MzggMTIuOTY4LTEwLjE0MyAwLjA0NjQgMC4wMzA0IDAuMDkzMyAwLjA2MTYgMC4xMzk2MyAwLjA5MTQtOC4wMTU2IDIuNzc4OC0xMi43ODcgNi4yNjYxLTEyLjc4NyAxMC4wNTEgMCA5LjA3NzYgMjcuNDI2IDE2LjQzNSA2MS4yNTcgMTYuNDM1czYxLjI1Ny03LjM1NzQgNjEuMjU3LTE2LjQzNWMwLTMuNzg1MS00Ljc3MTItNy4yNzI1LTEyLjc4OC0xMC4wNTEgMC4wNDc1LTAuMDI5OCAwLjA5MzctMC4wNjEgMC4xNC0wLjA5MTQgOC4xMjg4IDIuNzk4OSAxMi45NjkgNi4zMTg5IDEyLjk2OSAxMC4xNDMgMCA5LjEyMzUtMjcuNTcgMTYuNTIxLTYxLjU3OCAxNi41MjEiIGZpbGw9IiNlNmU2ZTQiLz4KICAgPHBhdGggaWQ9InBhdGg0MzUzIiBkPSJtNDUxLjI4IDkyOC42OWMtMzMuODMyIDAtNjEuMjU3LTcuMzU3NC02MS4yNTctMTYuNDM1IDAtMy43ODUxIDQuNzcxNS03LjI3MjUgMTIuNzg3LTEwLjA1MSAwLjA0NjQgMC4wMzEyIDAuMDk0MiAwLjA2MjUgMC4xNDA2MiAwLjA5MjItNy45MDYyIDIuNzU3OS0xMi42MDYgNi4yMTE1LTEyLjYwNiA5Ljk1OSAwIDkuMDI4OCAyNy4yODIgMTYuMzQ5IDYwLjkzNiAxNi4zNDlzNjAuOTM2LTcuMzE5OSA2MC45MzYtMTYuMzQ5YzAtMy43NDc1LTQuNy03LjIwMTEtMTIuNjA2LTkuOTU5IDAuMDQ2My0wLjAyOTcgMC4wOTM3LTAuMDYxIDAuMTQtMC4wOTIyIDguMDE2MiAyLjc3ODggMTIuNzg4IDYuMjY2MSAxMi43ODggMTAuMDUxIDAgOS4wNzc2LTI3LjQyNiAxNi40MzUtNjEuMjU3IDE2LjQzNSIgZmlsbD0iI2U1ZTVlMyIvPgogICA8cGF0aCBpZD0icGF0aDQzNTUiIGQ9Im00NTEuMjggOTI4LjZjLTMzLjY1NCAwLTYwLjkzNi03LjMxOTktNjAuOTM2LTE2LjM0OSAwLTMuNzQ3NSA0LjcwMDEtNy4yMDExIDEyLjYwNi05Ljk1OSAwLjA0NjQgMC4wMzAzIDAuMDk0NyAwLjA2MTUgMC4xNDExMyAwLjA5MjgtNy43OTU0IDIuNzM2NC0xMi40MjcgNi4xNTYyLTEyLjQyNyA5Ljg2NjIgMCA4Ljk4MTUgMjcuMTM4IDE2LjI2MyA2MC42MTUgMTYuMjYzczYwLjYxNi03LjI4MTIgNjAuNjE2LTE2LjI2M2MwLTMuNzEtNC42MzEyLTcuMTI5OS0xMi40MjgtOS44NjYyIDAuMDQ2Mi0wLjAzMTIgMC4wOTUtMC4wNjI1IDAuMTQxMjUtMC4wOTI4IDcuOTA2MiAyLjc1NzkgMTIuNjA2IDYuMjExNSAxMi42MDYgOS45NTkgMCA5LjAyODgtMjcuMjgyIDE2LjM0OS02MC45MzYgMTYuMzQ5IiBmaWxsPSIjZTRlNGUzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM1NyIgZD0ibTQ1MS4yOCA5MjguNTJjLTMzLjQ3NyAwLTYwLjYxNS03LjI4MTItNjAuNjE1LTE2LjI2MyAwLTMuNzEgNC42MzE0LTcuMTI5OSAxMi40MjctOS44NjYyIDAuMDQ2OSAwLjAyOTkgMC4wOTUzIDAuMDYxMSAwLjE0MTYyIDAuMDkxNC03LjY4NiAyLjcxNjItMTIuMjQ4IDYuMTAyNS0xMi4yNDggOS43NzQ5IDAgOC45MzM2IDI2Ljk5NSAxNi4xNzYgNjAuMjk1IDE2LjE3NnM2MC4yOTQtNy4yNDI2IDYwLjI5NC0xNi4xNzZjMC0zLjY3MjQtNC41NjEyLTcuMDU4Ni0xMi4yNDgtOS43NzQ5IDAuMDQ2My0wLjAzMDIgMC4wOTUtMC4wNjE1IDAuMTQxMjUtMC4wOTE0IDcuNzk2MiAyLjczNjQgMTIuNDI4IDYuMTU2MiAxMi40MjggOS44NjYyIDAgOC45ODE1LTI3LjEzOCAxNi4yNjMtNjAuNjE2IDE2LjI2MyIgZmlsbD0iI2U0ZTNlMiIvPgogICA8cGF0aCBpZD0icGF0aDQzNTkiIGQ9Im00NTEuMjggOTI4LjQzYy0zMy4zIDAtNjAuMjk1LTcuMjQyNi02MC4yOTUtMTYuMTc2IDAtMy42NzI0IDQuNTYyMS03LjA1ODYgMTIuMjQ4LTkuNzc0OSAwLjA0ODggMC4wMzIzIDAuMDkzMyAwLjA2MSAwLjE0MjUgMC4wOTIyLTcuNTc3NiAyLjY5MzktMTIuMDcgNi4wNDc5LTEyLjA3IDkuNjgyNiAwIDguODg2MiAyNi44NTEgMTYuMDkxIDU5Ljk3NCAxNi4wOTEgMzMuMTIyIDAgNTkuOTc0LTcuMjA1MSA1OS45NzQtMTYuMDkxIDAtMy42MzQ4LTQuNDkyNS02Ljk4ODgtMTIuMDctOS42ODI2IDAuMDQ4Ny0wLjAzMTIgMC4wOTM3LTAuMDYgMC4xNDI1LTAuMDkyMiA3LjY4NjIgMi43MTYyIDEyLjI0OCA2LjEwMjUgMTIuMjQ4IDkuNzc0OSAwIDguOTMzNi0yNi45OTQgMTYuMTc2LTYwLjI5NCAxNi4xNzYiIGZpbGw9IiNlM2UzZTEiLz4KICAgPHBhdGggaWQ9InBhdGg0MzYxIiBkPSJtNDUxLjI4IDkyOC4zNGMtMzMuMTIzIDAtNTkuOTc0LTcuMjA1MS01OS45NzQtMTYuMDkxIDAtMy42MzQ4IDQuNDkyMS02Ljk4ODggMTIuMDctOS42ODI2IDAuMDQ2NSAwLjAzMDMgMC4wOTYyIDAuMDYyNSAwLjE0MzEzIDAuMDkyOC03LjQ2NzggMi42NzI0LTExLjg5MiA1Ljk5MjItMTEuODkyIDkuNTg5OSAwIDguODM4OSAyNi43MDggMTYuMDA1IDU5LjY1MyAxNi4wMDVzNTkuNjUzLTcuMTY2IDU5LjY1My0xNi4wMDVjMC0zLjU5NzYtNC40MjM4LTYuOTE3NS0xMS44OTEtOS41ODk5IDAuMDQ2My0wLjAzMDMgMC4wOTYyLTAuMDYyNSAwLjE0MjUtMC4wOTI4IDcuNTc3NSAyLjY5MzkgMTIuMDcgNi4wNDc5IDEyLjA3IDkuNjgyNiAwIDguODg2Mi0yNi44NTIgMTYuMDkxLTU5Ljk3NCAxNi4wOTEiIGZpbGw9IiNlMmUyZTAiLz4KICAgPHBhdGggaWQ9InBhdGg0MzYzIiBkPSJtNDUxLjI4IDkyOC4yNmMtMzIuOTQ1IDAtNTkuNjUzLTcuMTY2LTU5LjY1My0xNi4wMDUgMC0zLjU5NzYgNC40MjQ0LTYuOTE3NSAxMS44OTItOS41ODk5IDAuMDQ4OSAwLjAzMTIgMC4wOTQyIDAuMDYwMSAwLjE0MzUgMC4wOTA5LTcuMzU4OSAyLjY1MjgtMTEuNzE1IDUuOTM5LTExLjcxNSA5LjQ5OSAwIDguNzkxIDI2LjU2NCAxNS45MTkgNTkuMzMyIDE1LjkxOXM1OS4zMzItNy4xMjggNTkuMzMyLTE1LjkxOWMwLTMuNTYtNC4zNTUtNi44NDYyLTExLjcxNC05LjQ5OSAwLjA0ODgtMC4wMzA4IDAuMDkzNy0wLjA1OTYgMC4xNDM3NS0wLjA5MDkgNy40Njc1IDIuNjcyNCAxMS44OTEgNS45OTIyIDExLjg5MSA5LjU4OTkgMCA4LjgzODktMjYuNzA4IDE2LjAwNS01OS42NTMgMTYuMDA1IiBmaWxsPSIjZTJlMWRmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM2NSIgZD0ibTQ1MS4yOCA5MjguMTdjLTMyLjc2OSAwLTU5LjMzMi03LjEyOC01OS4zMzItMTUuOTE5IDAtMy41NiA0LjM1NTktNi44NDYyIDExLjcxNS05LjQ5OSAwLjA0NjkgMC4wMzAzIDAuMDk3MiAwLjA2MyAwLjE0NDEyIDAuMDkyOC03LjI1MDUgMi42Mjk5LTExLjUzOCA1Ljg4MzgtMTEuNTM4IDkuNDA2MiAwIDguNzQzNiAyNi40MiAxNS44MzMgNTkuMDEyIDE1LjgzMyAzMi41OTEgMCA1OS4wMTItNy4wODg5IDU5LjAxMi0xNS44MzMgMC0zLjUyMjUtNC4yODc1LTYuNzc2NC0xMS41MzktOS40MDYyIDAuMDQ3NS0wLjAyOTggMC4wOTc1LTAuMDYyNSAwLjE0NS0wLjA5MjggNy4zNTg4IDIuNjUyOCAxMS43MTQgNS45MzkgMTEuNzE0IDkuNDk5IDAgOC43OTEtMjYuNTY0IDE1LjkxOS01OS4zMzIgMTUuOTE5IiBmaWxsPSIjZTFlMGRmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM2NyIgZD0ibTQ1MS4yOCA5MjguMDhjLTMyLjU5MSAwLTU5LjAxMi03LjA4ODktNTkuMDEyLTE1LjgzMyAwLTMuNTIyNSA0LjI4NzYtNi43NzY0IDExLjUzOC05LjQwNjIgMC4wNDkyIDAuMDMxMiAwLjA5NTYgMC4wNjEgMC4xNDUgMC4wOTIyLTcuMTQyMSAyLjYwODktMTEuMzYyIDUuODI5MS0xMS4zNjIgOS4zMTQgMCA4LjY5NjIgMjYuMjc3IDE1Ljc0NiA1OC42OTEgMTUuNzQ2czU4LjY5LTcuMDQ5OCA1OC42OS0xNS43NDZjMC0zLjQ4NDktNC4yMi02LjcwNTEtMTEuMzYyLTkuMzE0IDAuMDUtMC4wMzEyIDAuMDk2Mi0wLjA2MSAwLjE0NS0wLjA5MjIgNy4yNTEyIDIuNjI5OSAxMS41MzkgNS44ODM4IDExLjUzOSA5LjQwNjIgMCA4Ljc0MzYtMjYuNDIgMTUuODMzLTU5LjAxMiAxNS44MzMiIGZpbGw9IiNlMGUwZGUiLz4KICAgPHBhdGggaWQ9InBhdGg0MzY5IiBkPSJtNDUxLjI4IDkyOGMtMzIuNDE0IDAtNTguNjkxLTcuMDQ5OC01OC42OTEtMTUuNzQ2IDAtMy40ODQ5IDQuMjIwMi02LjcwNTEgMTEuMzYyLTkuMzE0IDAuMDQ2OSAwLjAzMDMgMC4wOTkxIDAuMDYyNSAwLjE0NiAwLjA5MjgtNy4wMzY2IDIuNTg2LTExLjE4OCA1Ljc3MzUtMTEuMTg4IDkuMjIxMiAwIDguNjQ4OSAyNi4xMzMgMTUuNjYgNTguMzcgMTUuNjZzNTguMzctNy4wMTEzIDU4LjM3LTE1LjY2YzAtMy40NDc4LTQuMTUxMi02LjYzNTItMTEuMTg4LTkuMjIxMiAwLjA0NjItMC4wMzAzIDAuMDk4Ny0wLjA2MjUgMC4xNDUtMC4wOTI4IDcuMTQyNSAyLjYwODkgMTEuMzYyIDUuODI5MSAxMS4zNjIgOS4zMTQgMCA4LjY5NjItMjYuMjc2IDE1Ljc0Ni01OC42OSAxNS43NDYiIGZpbGw9IiNlMGRmZGQiLz4KICAgPHBhdGggaWQ9InBhdGg0MzcxIiBkPSJtNDUxLjI4IDkyNy45MWMtMzIuMjM3IDAtNTguMzctNy4wMTEzLTU4LjM3LTE1LjY2IDAtMy40NDc4IDQuMTUwOS02LjYzNTIgMTEuMTg4LTkuMjIxMiAwLjA0OTIgMC4wMzEyIDAuMDk2NiAwLjA2MTEgMC4xNDYgMC4wOTI0LTYuOTI3MiAyLjU2NDktMTEuMDEzIDUuNzE4OC0xMS4wMTMgOS4xMjg5IDAgOC42MDExIDI1Ljk4OSAxNS41NzUgNTguMDQ5IDE1LjU3NXM1OC4wNDktNi45NzQxIDU4LjA0OS0xNS41NzVjMC0zLjQxMDEtNC4wODUtNi41NjQtMTEuMDEyLTkuMTI4OSAwLjA0ODctMC4wMzEyIDAuMDk2Mi0wLjA2MTEgMC4xNDYyNS0wLjA5MjQgNy4wMzYyIDIuNTg2IDExLjE4OCA1Ljc3MzUgMTEuMTg4IDkuMjIxMiAwIDguNjQ4OS0yNi4xMzQgMTUuNjYtNTguMzcgMTUuNjYiIGZpbGw9IiNkZmRlZGMiLz4KICAgPHBhdGggaWQ9InBhdGg0MzczIiBkPSJtNDUxLjI4IDkyNy44M2MtMzIuMDYgMC01OC4wNDktNi45NzQxLTU4LjA0OS0xNS41NzUgMC0zLjQxMDEgNC4wODU1LTYuNTY0IDExLjAxMy05LjEyODkgMC4wNDk3IDAuMDMxMiAwLjA5NzYgMC4wNjE1IDAuMTQ3IDAuMDkyOC02LjgyMTkgMi41NDI0LTEwLjgzOSA1LjY2MzYtMTAuODM5IDkuMDM2MSAwIDguNTUzOCAyNS44NDYgMTUuNDg5IDU3LjcyOCAxNS40ODlzNTcuNzI4LTYuOTM1IDU3LjcyOC0xNS40ODljMC0zLjM3MjUtNC4wMTYyLTYuNDkzNi0xMC44MzktOS4wMzYxIDAuMDUtMC4wMzEyIDAuMDk3NS0wLjA2MTUgMC4xNDc1LTAuMDkyOCA2LjkyNzUgMi41NjQ5IDExLjAxMiA1LjcxODggMTEuMDEyIDkuMTI4OSAwIDguNjAxMS0yNS45OSAxNS41NzUtNTguMDQ5IDE1LjU3NSIgZmlsbD0iI2RlZGRkYiIvPgogICA8cGF0aCBpZD0icGF0aDQzNzUiIGQ9Im00NTEuMjggOTI3Ljc0Yy0zMS44ODMgMC01Ny43MjgtNi45MzUtNTcuNzI4LTE1LjQ4OSAwLTMuMzcyNSA0LjAxNy02LjQ5MzYgMTAuODM5LTkuMDM2MSAwLjA0OTIgMC4wMzEyIDAuMDk4NiAwLjA2MSAwLjE0Nzg4IDAuMDkyMi02LjcxNTIgMi41MjE1LTEwLjY2NiA1LjYwODktMTAuNjY2IDguOTQzOSAwIDguNTA2NCAyNS43MDIgMTUuNDAyIDU3LjQwOCAxNS40MDJzNTcuNDA4LTYuODk2IDU3LjQwOC0xNS40MDJjMC0zLjMzNS0zLjk1MTItNi40MjI0LTEwLjY2Ni04Ljk0MzkgMC4wNDg4LTAuMDMxMiAwLjA5NzUtMC4wNjEgMC4xNDc1LTAuMDkyMiA2LjgyMjUgMi41NDI1IDEwLjgzOSA1LjY2MzYgMTAuODM5IDkuMDM2MSAwIDguNTUzOC0yNS44NDYgMTUuNDg5LTU3LjcyOCAxNS40ODkiIGZpbGw9IiNkZGRjZGEiLz4KICAgPHBhdGggaWQ9InBhdGg0Mzc3IiBkPSJtNDUxLjI4IDkyNy42NWMtMzEuNzA2IDAtNTcuNDA4LTYuODk2LTU3LjQwOC0xNS40MDIgMC0zLjMzNSAzLjk1MDgtNi40MjI0IDEwLjY2Ni04Ljk0MzkgMC4wNDk5IDAuMDMwMyAwLjA5ODYgMC4wNjE1IDAuMTQ4NSAwLjA5MjgtNi42MDk0IDIuNDk4Ni0xMC40OTQgNS41NTM4LTEwLjQ5NCA4Ljg1MTEgMCA4LjQ1OSAyNS41NTkgMTUuMzE2IDU3LjA4NyAxNS4zMTZzNTcuMDg3LTYuODU3NCA1Ny4wODctMTUuMzE2YzAtMy4yOTc0LTMuODgzOC02LjM1MjUtMTAuNDk0LTguODUxMSAwLjA1LTAuMDMxMiAwLjA5ODctMC4wNjI1IDAuMTQ4NzUtMC4wOTI4IDYuNzE1IDIuNTIxNSAxMC42NjYgNS42MDg5IDEwLjY2NiA4Ljk0MzkgMCA4LjUwNjQtMjUuNzAyIDE1LjQwMi01Ny40MDggMTUuNDAyIiBmaWxsPSIjZGRkY2RhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM3OSIgZD0ibTQ1MS4yOCA5MjcuNTdjLTMxLjUyOCAwLTU3LjA4Ny02Ljg1NzQtNTcuMDg3LTE1LjMxNiAwLTMuMjk3NCAzLjg4NDgtNi4zNTI1IDEwLjQ5NC04Ljg1MTEgMC4wNDk3IDAuMDI5OSAwLjA5OTYgMC4wNjExIDAuMTQ4ODcgMC4wOTI0LTYuNTAyOSAyLjQ3NzUtMTAuMzIyIDUuNDk5LTEwLjMyMiA4Ljc1ODggMCA4LjQxMTEgMjUuNDE1IDE1LjIzIDU2Ljc2NiAxNS4yM3M1Ni43NjctNi44MTg4IDU2Ljc2Ny0xNS4yM2MwLTMuMjU5OC0zLjgyLTYuMjgxMi0xMC4zMjItOC43NTg4IDAuMDQ4Ny0wLjAzMTIgMC4wOTg3LTAuMDYyNSAwLjE0ODc1LTAuMDkyNCA2LjYxIDIuNDk4NiAxMC40OTQgNS41NTM4IDEwLjQ5NCA4Ljg1MTEgMCA4LjQ1OS0yNS41NTggMTUuMzE2LTU3LjA4NyAxNS4zMTYiIGZpbGw9IiNkY2RiZDkiLz4KICAgPHBhdGggaWQ9InBhdGg0MzgxIiBkPSJtNDUxLjI4IDkyNy40OGMtMzEuMzUxIDAtNTYuNzY2LTYuODE4OC01Ni43NjYtMTUuMjMgMC0zLjI1OTggMy44MTg5LTYuMjgxMiAxMC4zMjItOC43NTg4IDAuMDQ5OSAwLjAzMDMgMC4xMDA2MyAwLjA2MSAwLjE0OTg4IDAuMDkyMy02LjM5NzQgMi40NTUxLTEwLjE1MSA1LjQ0MzktMTAuMTUxIDguNjY2NSAwIDguMzYzOCAyNS4yNzEgMTUuMTQ0IDU2LjQ0NSAxNS4xNDRzNTYuNDQ2LTYuNzc5OCA1Ni40NDYtMTUuMTQ0YzAtMy4yMjI2LTMuNzUzOC02LjIxMTQtMTAuMTUxLTguNjY2NSAwLjA1LTAuMDMxMiAwLjEtMC4wNjIgMC4xNS0wLjA5MjMgNi41MDI1IDIuNDc3NSAxMC4zMjIgNS40OTkgMTAuMzIyIDguNzU4OCAwIDguNDExMS0yNS40MTYgMTUuMjMtNTYuNzY3IDE1LjIzIiBmaWxsPSIjZGJkYWQ4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM4MyIgZD0ibTQ1MS4yOCA5MjcuNGMtMzEuMTc0IDAtNTYuNDQ1LTYuNzc5OC01Ni40NDUtMTUuMTQ0IDAtMy4yMjI2IDMuNzUzNS02LjIxMTQgMTAuMTUxLTguNjY2NSAwLjA0OTkgMC4wMzAzIDAuMTAxMTIgMC4wNjE1IDAuMTUwODcgMC4wOTI4LTYuMjkzNCAyLjQzMjYtOS45ODE0IDUuMzg3Ny05Ljk4MTQgOC41NzM3IDAgOC4zMTY0IDI1LjEyOCAxNS4wNTggNTYuMTI1IDE1LjA1OCAzMC45OTYgMCA1Ni4xMjQtNi43NDEyIDU2LjEyNC0xNS4wNTggMC0zLjE4Ni0zLjY4NzUtNi4xNDExLTkuOTgxMi04LjU3MzggMC4wNS0wLjAzMTIgMC4xMDEyNS0wLjA2MjUgMC4xNTEyNS0wLjA5MjggNi4zOTc1IDIuNDU1MSAxMC4xNTEgNS40NDM5IDEwLjE1MSA4LjY2NjUgMCA4LjM2MzgtMjUuMjcyIDE1LjE0NC01Ni40NDYgMTUuMTQ0IiBmaWxsPSIjZGFkOWQ3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM4NSIgZD0ibTQ1MS4yOCA5MjcuMzFjLTMwLjk5NyAwLTU2LjEyNS02Ljc0MTItNTYuMTI1LTE1LjA1OCAwLTMuMTg2IDMuNjg4LTYuMTQxMSA5Ljk4MTQtOC41NzM4IDAuMDQ5OSAwLjAyOTggMC4xMDE2MyAwLjA2MSAwLjE1MTM4IDAuMDkyMy02LjE4ODUgMi40MTE2LTkuODEyIDUuMzMyNi05LjgxMiA4LjQ4MTUgMCA4LjI2ODUgMjQuOTg0IDE0Ljk3MyA1NS44MDQgMTQuOTczIDMwLjgxOSAwIDU1LjgwNC02LjcwNDIgNTUuODA0LTE0Ljk3MyAwLTMuMTQ4OS0zLjYyMzgtNi4wNjk5LTkuODEyNS04LjQ4MTUgMC4wNS0wLjAzMTIgMC4xMDI1LTAuMDYyNSAwLjE1MTI1LTAuMDkyMyA2LjI5MzggMi40MzI2IDkuOTgxMiA1LjM4NzggOS45ODEyIDguNTczOCAwIDguMzE2NC0yNS4xMjggMTUuMDU4LTU2LjEyNCAxNS4wNTgiIGZpbGw9IiNkOWQ4ZDYiLz4KICAgPHBhdGggaWQ9InBhdGg0Mzg3IiBkPSJtNDUxLjI4IDkyNy4yM2MtMzAuODIgMC01NS44MDQtNi43MDQyLTU1LjgwNC0xNC45NzMgMC0zLjE0ODkgMy42MjM1LTYuMDY5OSA5LjgxMi04LjQ4MTUgMC4wNTIzIDAuMDMxMiAwLjA5OTYgMC4wNjE2IDAuMTUxODcgMC4wOTI5LTYuMDg0NSAyLjM4ODYtOS42NDMgNS4yNzczLTkuNjQzIDguMzg4NiAwIDguMjIxMiAyNC44NDEgMTQuODg2IDU1LjQ4MyAxNC44ODZzNTUuNDgzLTYuNjY1IDU1LjQ4My0xNC44ODZjMC0zLjExMTQtMy41NTg4LTYtOS42NDI1LTguMzg4NiAwLjA1MTItMC4wMzEyIDAuMDk4Ny0wLjA2MTYgMC4xNTEyNS0wLjA5MjkgNi4xODg4IDIuNDExNiA5LjgxMjUgNS4zMzI2IDkuODEyNSA4LjQ4MTUgMCA4LjI2ODUtMjQuOTg1IDE0Ljk3My01NS44MDQgMTQuOTczIiBmaWxsPSIjZDhkN2Q2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM4OSIgZD0ibTQ1MS4yOCA5MjcuMTRjLTMwLjY0MyAwLTU1LjQ4My02LjY2NS01NS40ODMtMTQuODg2IDAtMy4xMTE0IDMuNTU4NS02IDkuNjQzLTguMzg4NiAwLjA1MDMgMC4wMjk4IDAuMTAzIDAuMDYyNSAwLjE1Mjg4IDAuMDkyMy01Ljk4MTUgMi4zNjYzLTkuNDc1MSA1LjIyMTEtOS40NzUxIDguMjk2NCAwIDguMTczOSAyNC42OTcgMTQuOCA1NS4xNjMgMTQuOCAzMC40NjUgMCA1NS4xNjItNi42MjU5IDU1LjE2Mi0xNC44IDAtMy4wNzUyLTMuNDkyNS01LjkzMDEtOS40NzUtOC4yOTY0IDAuMDUtMC4wMjk4IDAuMTAzNzUtMC4wNjI1IDAuMTUzNzUtMC4wOTIzIDYuMDgzOCAyLjM4ODYgOS42NDI1IDUuMjc3MyA5LjY0MjUgOC4zODg2IDAgOC4yMjEyLTI0Ljg0IDE0Ljg4Ni01NS40ODMgMTQuODg2IiBmaWxsPSIjZDdkNmQ1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDM5MSIgZD0ibTQ1MS4yOCA5MjcuMDVjLTMwLjQ2NSAwLTU1LjE2My02LjYyNTktNTUuMTYzLTE0LjggMC0zLjA3NTIgMy40OTM2LTUuOTMwMSA5LjQ3NTEtOC4yOTY0IDAuMDUyOCAwLjAzMTIgMC4xMDE1IDAuMDYxNSAwLjE1Mzc1IDAuMDkyOC01Ljg3NzkgMi4zNDM4LTkuMzA4MSA1LjE2Ni05LjMwODEgOC4yMDM2IDAgOC4xMjY1IDI0LjU1MyAxNC43MTQgNTQuODQyIDE0LjcxNCAzMC4yODggMCA1NC44NDItNi41ODc0IDU0Ljg0Mi0xNC43MTQgMC0zLjAzNzYtMy40My01Ljg1OTktOS4zMDg4LTguMjAzNiAwLjA1MjUtMC4wMzEyIDAuMTAxMjUtMC4wNjE1IDAuMTUzNzUtMC4wOTI4IDUuOTgyNSAyLjM2NjMgOS40NzUgNS4yMjExIDkuNDc1IDguMjk2NCAwIDguMTczOS0yNC42OTYgMTQuOC01NS4xNjIgMTQuOCIgZmlsbD0iI2Q2ZDZkNCIvPgogICA8cGF0aCBpZD0icGF0aDQzOTMiIGQ9Im00NTEuMjggOTI2Ljk3Yy0zMC4yODkgMC01NC44NDItNi41ODc0LTU0Ljg0Mi0xNC43MTQgMC0zLjAzNzYgMy40MzAyLTUuODU5OSA5LjMwODEtOC4yMDM2IDAuMDUwNCAwLjAyOTcgMC4xMDQgMC4wNjI1IDAuMTU0MzcgMC4wOTIyLTUuNzc1NCAyLjMyMjctOS4xNDE2IDUuMTExNC05LjE0MTYgOC4xMTE0IDAgOC4wNzg2IDI0LjQxIDE0LjYyNyA1NC41MjEgMTQuNjI3czU0LjUyLTYuNTQ4OCA1NC41Mi0xNC42MjdjMC0zLTMuMzY2Mi01Ljc4ODYtOS4xNDEyLTguMTExNCAwLjA1LTAuMDI5NyAwLjEwMzc1LTAuMDYyNSAwLjE1Mzc1LTAuMDkyMiA1Ljg3ODggMi4zNDM4IDkuMzA4OCA1LjE2NiA5LjMwODggOC4yMDM2IDAgOC4xMjY1LTI0LjU1NCAxNC43MTQtNTQuODQyIDE0LjcxNCIgZmlsbD0iI2Q1ZDVkMyIvPgogICA8cGF0aCBpZD0icGF0aDQzOTUiIGQ9Im00NTEuMjggOTI2Ljg4Yy0zMC4xMTEgMC01NC41MjEtNi41NDg4LTU0LjUyMS0xNC42MjcgMC0zIDMuMzY2Mi01Ljc4ODYgOS4xNDE2LTguMTExNCAwLjA1MjggMC4wMzI4IDAuMTAzIDAuMDYyNSAwLjE1NTI1IDAuMDkzNy01LjY3MjQgMi4yOTg5LTguOTc2MSA1LjA1NTItOC45NzYxIDguMDE3NiAwIDguMDMxMiAyNC4yNjYgMTQuNTQxIDU0LjIgMTQuNTQxczU0LjItNi41MDk4IDU0LjItMTQuNTQxYzAtMi45NjI0LTMuMzAzOC01LjcxODgtOC45NzYyLTguMDE3NiAwLjA1MjUtMC4wMzEyIDAuMTAyNS0wLjA2MSAwLjE1NS0wLjA5MzcgNS43NzUgMi4zMjI3IDkuMTQxMiA1LjExMTQgOS4xNDEyIDguMTExNCAwIDguMDc4Ni0yNC40MSAxNC42MjctNTQuNTIgMTQuNjI3IiBmaWxsPSIjZDRkNGQyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM5NyIgZD0ibTQ1MS4yOCA5MjYuNzljLTI5LjkzNCAwLTU0LjItNi41MDk4LTU0LjItMTQuNTQxIDAtMi45NjI0IDMuMzAzOC01LjcxODggOC45NzYxLTguMDE3NiAwLjA1MjggMC4wMzEyIDAuMTAzNSAwLjA2MTYgMC4xNTYyNSAwLjA5MjktNS41NzA5IDIuMjc1OS04LjgxMTUgNC45OTg1LTguODExNSA3LjkyNDggMCA3Ljk4MzkgMjQuMTIyIDE0LjQ1NSA1My44NzkgMTQuNDU1czUzLjg3OS02LjQ3MTIgNTMuODc5LTE0LjQ1NWMwLTIuOTI2Mi0zLjI0MTItNS42NDg5LTguODExMi03LjkyNDggMC4wNTI1LTAuMDMxMiAwLjEwMzc1LTAuMDYxNiAwLjE1NjI1LTAuMDkyOSA1LjY3MjUgMi4yOTg5IDguOTc2MiA1LjA1NTIgOC45NzYyIDguMDE3NiAwIDguMDMxMi0yNC4yNjcgMTQuNTQxLTU0LjIgMTQuNTQxIiBmaWxsPSIjZDRkM2QxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDM5OSIgZD0ibTQ1MS4yOCA5MjYuNzFjLTI5Ljc1NyAwLTUzLjg3OS02LjQ3MTItNTMuODc5LTE0LjQ1NSAwLTIuOTI2MiAzLjI0MDYtNS42NDg5IDguODExNS03LjkyNDggMC4wNTI4IDAuMDMxMiAwLjEwNCAwLjA2MSAwLjE1Njc1IDAuMDkyMy01LjQ3MDIgMi4yNTM5LTguNjQ3NSA0Ljk0MzktOC42NDc1IDcuODMyNSAwIDcuOTM2IDIzLjk3OSAxNC4zNyA1My41NTkgMTQuMzdzNTMuNTU4LTYuNDM0MSA1My41NTgtMTQuMzdjMC0yLjg4ODYtMy4xNzc1LTUuNTc4Ni04LjY0NzUtNy44MzI1IDAuMDUzOC0wLjAzMTIgMC4xMDUtMC4wNjEgMC4xNTc1LTAuMDkyMyA1LjU3IDIuMjc1OSA4LjgxMTIgNC45OTg1IDguODExMiA3LjkyNDggMCA3Ljk4MzktMjQuMTIyIDE0LjQ1NS01My44NzkgMTQuNDU1IiBmaWxsPSIjZDNkMmQwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQwMSIgZD0ibTQ1MS4yOCA5MjYuNjJjLTI5LjU4IDAtNTMuNTU5LTYuNDM0MS01My41NTktMTQuMzcgMC0yLjg4ODYgMy4xNzcyLTUuNTc4NiA4LjY0NzUtNy44MzI1IDAuMDUwMyAwLjAyOTcgMC4xMDY4OCAwLjA2NCAwLjE1NzYzIDAuMDkzNy01LjM2ODYgMi4yMy04LjQ4NDQgNC44ODYyLTguNDg0NCA3LjczODggMCA3Ljg4ODYgMjMuODM2IDE0LjI4NCA1My4yMzggMTQuMjg0czUzLjIzOC02LjM5NTEgNTMuMjM4LTE0LjI4NGMwLTIuODUyNS0zLjExNjItNS41MDg4LTguNDg1LTcuNzM4OCAwLjA1MTItMC4wMjk4IDAuMTA3NS0wLjA2NCAwLjE1NzUtMC4wOTM3IDUuNDcgMi4yNTM5IDguNjQ3NSA0Ljk0MzkgOC42NDc1IDcuODMyNSAwIDcuOTM2LTIzLjk3OCAxNC4zNy01My41NTggMTQuMzciIGZpbGw9IiNkMmQxY2YiLz4KICAgPHBhdGggaWQ9InBhdGg0NDAzIiBkPSJtNDUxLjI4IDkyNi41NGMtMjkuNDAyIDAtNTMuMjM4LTYuMzk1MS01My4yMzgtMTQuMjg0IDAtMi44NTI1IDMuMTE1OC01LjUwODggOC40ODQ0LTcuNzM4OCAwLjA1MjggMC4wMzEyIDAuMTA1NSAwLjA2MjUgMC4xNTgyNSAwLjA5MjItNS4yNjg1IDIuMjA3NS04LjMyMTggNC44MzE1LTguMzIxOCA3LjY0NjUgMCA3Ljg0MTIgMjMuNjkxIDE0LjE5NyA1Mi45MTcgMTQuMTk3IDI5LjIyNSAwIDUyLjkxNy02LjM1NiA1Mi45MTctMTQuMTk3IDAtMi44MTUtMy4wNTM4LTUuNDM5LTguMzIxMi03LjY0NjUgMC4wNTI1LTAuMDMxMiAwLjEwNS0wLjA2MSAwLjE1NzUtMC4wOTIyIDUuMzY4OCAyLjIzIDguNDg1IDQuODg2MiA4LjQ4NSA3LjczODggMCA3Ljg4ODYtMjMuODM2IDE0LjI4NC01My4yMzggMTQuMjg0IiBmaWxsPSIjZDFkMGNmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQwNSIgZD0ibTQ1MS4yOCA5MjYuNDVjLTI5LjIyNiAwLTUyLjkxNy02LjM1Ni01Mi45MTctMTQuMTk3IDAtMi44MTUgMy4wNTMyLTUuNDM5IDguMzIxOC03LjY0NjUgMC4wNTUxIDAuMDMyOCAwLjEwNCAwLjA2MTUgMC4xNTkyNSAwLjA5MzctNS4xNjggMi4xODQxLTguMTYwOCA0Ljc3NC04LjE2MDggNy41NTI4IDAgNy43OTQgMjMuNTQ5IDE0LjExMSA1Mi41OTcgMTQuMTExczUyLjU5Ny02LjMxNzMgNTIuNTk3LTE0LjExMWMwLTIuNzc4OC0yLjk5MjUtNS4zNjg2LTguMTYxMi03LjU1MjggMC4wNTUtMC4wMzIyIDAuMTAzNzUtMC4wNjEgMC4xNi0wLjA5MzcgNS4yNjc1IDIuMjA3NSA4LjMyMTIgNC44MzE1IDguMzIxMiA3LjY0NjUgMCA3Ljg0MTItMjMuNjkyIDE0LjE5Ny01Mi45MTcgMTQuMTk3IiBmaWxsPSIjZDBjZmNkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQwNyIgZD0ibTQ1MS4yOCA5MjYuMzZjLTI5LjA0OCAwLTUyLjU5Ny02LjMxNzMtNTIuNTk3LTE0LjExMSAwLTIuNzc4OCAyLjk5MjgtNS4zNjg2IDguMTYwOC03LjU1MjggMC4wNTMxIDAuMDMwMyAwLjEwNzM3IDAuMDYyNSAwLjE2MDEyIDAuMDkyOC01LjA2ODQgMi4xNjExLTcuOTk5NSA0LjcxODgtNy45OTk1IDcuNDYgMCA3Ljc0NjEgMjMuNDA0IDE0LjAyNSA1Mi4yNzUgMTQuMDI1czUyLjI3Ni02LjI3ODggNTIuMjc2LTE0LjAyNWMwLTIuNzQxMi0yLjkzMTItNS4yOTg5LTgtNy40NiAwLjA1MjUtMC4wMzAzIDAuMTA3NS0wLjA2MjUgMC4xNi0wLjA5MjggNS4xNjg4IDIuMTg0MSA4LjE2MTIgNC43NzQgOC4xNjEyIDcuNTUyOCAwIDcuNzk0LTIzLjU0OSAxNC4xMTEtNTIuNTk3IDE0LjExMSIgZmlsbD0iI2NmY2VjYyIvPgogICA8cGF0aCBpZD0icGF0aDQ0MDkiIGQ9Im00NTEuMjggOTI2LjI4Yy0yOC44NzEgMC01Mi4yNzUtNi4yNzg4LTUyLjI3NS0xNC4wMjUgMC0yLjc0MTIgMi45MzExLTUuMjk4OSA3Ljk5OTUtNy40NiAwLjA1MzIgMC4wMzEyIDAuMTA4MzggMC4wNjI1IDAuMTYxMTMgMC4wOTM3LTQuOTY4OCAyLjEzNzItNy44NDA0IDQuNjYxMS03Ljg0MDQgNy4zNjYyIDAgNy42OTg4IDIzLjI2MSAxMy45NCA1MS45NTUgMTMuOTRzNTEuOTU0LTYuMjQxMiA1MS45NTQtMTMuOTRjMC0yLjcwNTEtMi44NzEyLTUuMjI5LTcuODQtNy4zNjYyIDAuMDUzOC0wLjAzMTIgMC4xMDg3NS0wLjA2MjUgMC4xNjEyNS0wLjA5MzcgNS4wNjg4IDIuMTYxMSA4IDQuNzE4OCA4IDcuNDYgMCA3Ljc0NjEtMjMuNDA0IDE0LjAyNS01Mi4yNzYgMTQuMDI1IiBmaWxsPSIjY2VjZGNiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQxMSIgZD0ibTQ1MS4yOCA5MjYuMTljLTI4LjY5NCAwLTUxLjk1NS02LjI0MTItNTEuOTU1LTEzLjk0IDAtMi43MDUxIDIuODcxNi01LjIyOSA3Ljg0MDQtNy4zNjYyIDAuMDUzMiAwLjAyOTkgMC4xMDg4NyAwLjA2MjUgMC4xNjIxMyAwLjA5MjQtNC44NzA2IDIuMTEzOC03LjY4MTYgNC42MDY0LTcuNjgxNiA3LjI3MzkgMCA3LjY1MTQgMjMuMTE4IDEzLjg1NCA1MS42MzQgMTMuODU0IDI4LjUxNyAwIDUxLjYzNC02LjIwMjEgNTEuNjM0LTEzLjg1NCAwLTIuNjY3NS0yLjgxMTItNS4xNjAxLTcuNjgxMi03LjI3MzkgMC4wNTI1LTAuMDI5OSAwLjEwODc1LTAuMDYyNSAwLjE2MTI1LTAuMDkyNCA0Ljk2ODggMi4xMzcyIDcuODQgNC42NjExIDcuODQgNy4zNjYyIDAgNy42OTg4LTIzLjI2IDEzLjk0LTUxLjk1NCAxMy45NCIgZmlsbD0iI2NkY2NjYSIvPgogICA8cGF0aCBpZD0icGF0aDQ0MTMiIGQ9Im00NTEuMjggOTI2LjExYy0yOC41MTcgMC01MS42MzQtNi4yMDIxLTUxLjYzNC0xMy44NTQgMC0yLjY2NzUgMi44MTEtNS4xNjAxIDcuNjgxNi03LjI3MzkgMC4wNTUxIDAuMDMyNiAwLjEwNjg3IDAuMDYxNSAwLjE2MjQ5IDAuMDkzNy00Ljc3MjkgMi4wOTAyLTcuNTIyOSA0LjU0ODctNy41MjI5IDcuMTgwMSAwIDcuNjAzNSAyMi45NzQgMTMuNzY4IDUxLjMxMyAxMy43NjhzNTEuMzEzLTYuMTY0MSA1MS4zMTMtMTMuNzY4YzAtMi42MzE0LTIuNzUtNS4wODk5LTcuNTIzOC03LjE4MDEgMC4wNTYzLTAuMDMyMyAwLjEwNzUtMC4wNjExIDAuMTYzNzUtMC4wOTM3IDQuODcgMi4xMTM4IDcuNjgxMiA0LjYwNjQgNy42ODEyIDcuMjczOSAwIDcuNjUxNC0yMy4xMTggMTMuODU0LTUxLjYzNCAxMy44NTQiIGZpbGw9IiNjY2NiYzkiLz4KICAgPHBhdGggaWQ9InBhdGg0NDE1IiBkPSJtNDUxLjI4IDkyNi4wMmMtMjguMzM5IDAtNTEuMzEzLTYuMTY0MS01MS4zMTMtMTMuNzY4IDAtMi42MzE0IDIuNzUtNS4wODk5IDcuNTIyOS03LjE4MDEgMC4wNTMyIDAuMDMwMiAwLjExMDM4IDAuMDYyNSAwLjE2MzYzIDAuMDkyNy00LjY3NTIgMi4wNjc0LTcuMzY2MiA0LjQ5MjEtNy4zNjYyIDcuMDg3NCAwIDcuNTU2MSAyMi44MyAxMy42ODEgNTAuOTkzIDEzLjY4MXM1MC45OTMtNi4xMjUgNTAuOTkzLTEzLjY4MWMwLTIuNTk1Mi0yLjY5MTItNS4wMi03LjM2NjItNy4wODc0IDAuMDUyNS0wLjAzMDIgMC4xMS0wLjA2MjUgMC4xNjI1LTAuMDkyNyA0Ljc3MzggMi4wOTAyIDcuNTIzOCA0LjU0ODcgNy41MjM4IDcuMTgwMSAwIDcuNjAzNS0yMi45NzQgMTMuNzY4LTUxLjMxMyAxMy43NjgiIGZpbGw9IiNjYmM5YzgiLz4KICAgPHBhdGggaWQ9InBhdGg0NDE3IiBkPSJtNDUxLjI4IDkyNS45M2MtMjguMTYzIDAtNTAuOTkzLTYuMTI1LTUwLjk5My0xMy42ODEgMC0yLjU5NTIgMi42OTEtNS4wMiA3LjM2NjItNy4wODc0IDAuMDU1NiAwLjAzMjMgMC4xMDg4NyAwLjA2MjUgMC4xNjQ1IDAuMDkzNy00LjU3NzEgMi4wNDM0LTcuMjA5OSA0LjQzNi03LjIwOTkgNi45OTM2IDAgNy41MDg4IDIyLjY4NiAxMy41OTUgNTAuNjcyIDEzLjU5NSAyNy45ODUgMCA1MC42NzItNi4wODY1IDUwLjY3Mi0xMy41OTUgMC0yLjU1NzYtMi42MzI1LTQuOTUwMi03LjIxLTYuOTkzNiAwLjA1NS0wLjAzMTIgMC4xMDg3NS0wLjA2MTUgMC4xNjUtMC4wOTM3IDQuNjc1IDIuMDY3NCA3LjM2NjIgNC40OTIxIDcuMzY2MiA3LjA4NzQgMCA3LjU1NjEtMjIuODMgMTMuNjgxLTUwLjk5MyAxMy42ODEiIGZpbGw9IiNjYWM4YzciLz4KICAgPHBhdGggaWQ9InBhdGg0NDE5IiBkPSJtNDUxLjI4IDkyNS44NWMtMjcuOTg1IDAtNTAuNjcyLTYuMDg2NS01MC42NzItMTMuNTk1IDAtMi41NTc2IDIuNjMyOC00Ljk1MDIgNy4yMDk5LTYuOTkzNiAwLjA1NjMgMC4wMzEyIDAuMTA5ODcgMC4wNjI1IDAuMTY1NjIgMC4wOTM3LTQuNDgwNSAyLjAyLTcuMDU0OCA0LjM3ODQtNy4wNTQ4IDYuODk5OSAwIDcuNDYxNCAyMi41NDMgMTMuNTA5IDUwLjM1MSAxMy41MDlzNTAuMzUtNi4wNDc0IDUwLjM1LTEzLjUwOWMwLTIuNTIxNS0yLjU3MzgtNC44Nzk5LTcuMDU1LTYuODk5OSAwLjA1NjMtMC4wMzEyIDAuMTEtMC4wNjI1IDAuMTY2MjUtMC4wOTM3IDQuNTc3NSAyLjA0MzQgNy4yMSA0LjQzNiA3LjIxIDYuOTkzNiAwIDcuNTA4OC0yMi42ODYgMTMuNTk1LTUwLjY3MiAxMy41OTUiIGZpbGw9IiNjOWM3YzYiLz4KICAgPHBhdGggaWQ9InBhdGg0NDIxIiBkPSJtNDUxLjI4IDkyNS43NmMtMjcuODA4IDAtNTAuMzUxLTYuMDQ3NC01MC4zNTEtMTMuNTA5IDAtMi41MjE1IDIuNTc0Mi00Ljg3OTkgNy4wNTQ4LTYuODk5OSAwLjA1NjEgMC4wMzEyIDAuMTEwNzUgMC4wNjI1IDAuMTY2NSAwLjA5MzctNC4zODUyIDEuOTk0Ni02LjkwMDQgNC4zMjIyLTYuOTAwNCA2LjgwNjEgMCA3LjQxMzYgMjIuMzk5IDEzLjQyMiA1MC4wMyAxMy40MjJzNTAuMDMtNi4wMDg3IDUwLjAzLTEzLjQyMmMwLTIuNDgzOS0yLjUxNS00LjgxMTUtNi45MDEyLTYuODA2MSAwLjA1NjMtMC4wMzEyIDAuMTExMjUtMC4wNjI1IDAuMTY2MjUtMC4wOTM3IDQuNDgxMiAyLjAyIDcuMDU1IDQuMzc4NCA3LjA1NSA2Ljg5OTkgMCA3LjQ2MTQtMjIuNTQyIDEzLjUwOS01MC4zNSAxMy41MDkiIGZpbGw9IiNjOGM2YzUiLz4KICAgPHBhdGggaWQ9InBhdGg0NDIzIiBkPSJtNDUxLjI4IDkyNS42N2MtMjcuNjMxIDAtNTAuMDMtNi4wMDg3LTUwLjAzLTEzLjQyMiAwLTIuNDgzOSAyLjUxNTEtNC44MTE1IDYuOTAwNC02LjgwNjEgMC4wNTYxIDAuMDMxMiAwLjExMTI1IDAuMDYyNSAwLjE2NzUgMC4wOTM3LTQuMjkwMSAxLjk3MTEtNi43NDcxIDQuMjY0Ni02Ljc0NzEgNi43MTI0IDAgNy4zNjYyIDIyLjI1NiAxMy4zMzcgNDkuNzEgMTMuMzM3czQ5LjcwOS01Ljk3MTIgNDkuNzA5LTEzLjMzN2MwLTIuNDQ3OC0yLjQ1NzUtNC43NDEyLTYuNzQ3NS02LjcxMjQgMC4wNTYzLTAuMDMxMiAwLjExMjUtMC4wNjI1IDAuMTY3NS0wLjA5MzcgNC4zODYyIDEuOTk0NiA2LjkwMTIgNC4zMjIyIDYuOTAxMiA2LjgwNjEgMCA3LjQxMzYtMjIuNCAxMy40MjItNTAuMDMgMTMuNDIyIiBmaWxsPSIjYzdjNWMzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQyNSIgZD0ibTQ1MS4yOCA5MjUuNTljLTI3LjQ1NCAwLTQ5LjcxLTUuOTcxMi00OS43MS0xMy4zMzcgMC0yLjQ0NzggMi40NTctNC43NDEyIDYuNzQ3MS02LjcxMjQgMC4wNTYxIDAuMDMxMiAwLjExMjI1IDAuMDYyNSAwLjE2ODM4IDAuMDkyMi00LjE5NDIgMS45NDg4LTYuNTk0OCA0LjIwOS02LjU5NDggNi42MjAxIDAgNy4zMTg5IDIyLjExMiAxMy4yNTEgNDkuMzg5IDEzLjI1MSAyNy4yNzYgMCA0OS4zODktNS45MzI2IDQ5LjM4OS0xMy4yNTEgMC0yLjQxMTEtMi40MDEyLTQuNjcxNC02LjU5NS02LjYyMDEgMC4wNTYzLTAuMDI5NyAwLjExMjUtMC4wNjEgMC4xNjc1LTAuMDkyMiA0LjI5IDEuOTcxMSA2Ljc0NzUgNC4yNjQ2IDYuNzQ3NSA2LjcxMjQgMCA3LjM2NjItMjIuMjU2IDEzLjMzNy00OS43MDkgMTMuMzM3IiBmaWxsPSIjYzZjNGMyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQyNyIgZD0ibTQ1MS4yOCA5MjUuNWMtMjcuMjc3IDAtNDkuMzg5LTUuOTMyNi00OS4zODktMTMuMjUxIDAtMi40MTExIDIuNDAwNS00LjY3MTQgNi41OTQ4LTYuNjIwMSAwLjA1NjEgMC4wMzEyIDAuMTEzMjUgMC4wNjQgMC4xNjk1IDAuMDkzNy00LjEwMDEgMS45MjM5LTYuNDQzNCA0LjE1MTQtNi40NDM0IDYuNTI2NCAwIDcuMjcxIDIxLjk2OCAxMy4xNjUgNDkuMDY4IDEzLjE2NSAyNy4wOTkgMCA0OS4wNjgtNS44OTQgNDkuMDY4LTEzLjE2NSAwLTIuMzc1LTIuMzQzOC00LjYwMjUtNi40NDM4LTYuNTI2NCAwLjA1NjItMC4wMjk3IDAuMTEzNzUtMC4wNjI1IDAuMTctMC4wOTM3IDQuMTkzOCAxLjk0ODggNi41OTUgNC4yMDkgNi41OTUgNi42MjAxIDAgNy4zMTg5LTIyLjExMyAxMy4yNTEtNDkuMzg5IDEzLjI1MSIgZmlsbD0iI2M0YzNjMSIvPgogICA8cGF0aCBpZD0icGF0aDQ0MjkiIGQ9Im00NTEuMjggOTI1LjQyYy0yNy4xIDAtNDkuMDY4LTUuODk0LTQ5LjA2OC0xMy4xNjUgMC0yLjM3NSAyLjM0MzItNC42MDI1IDYuNDQzNC02LjUyNjQgMC4wNTYxIDAuMDMxMiAwLjExMzc1IDAuMDY0IDAuMTY5ODcgMC4wOTM3LTQuMDA1NCAxLjg5OTktNi4yOTI1IDQuMDk1My02LjI5MjUgNi40MzI2IDAgNy4yMjM2IDIxLjgyNSAxMy4wNzkgNDguNzQ3IDEzLjA3OXM0OC43NDctNS44NTUgNDguNzQ3LTEzLjA3OWMwLTIuMzM3NC0yLjI4NzUtNC41MzI4LTYuMjkyNS02LjQzMjYgMC4wNTYzLTAuMDI5NyAwLjExMzc1LTAuMDYyNSAwLjE3LTAuMDkzNyA0LjEgMS45MjM5IDYuNDQzOCA0LjE1MTQgNi40NDM4IDYuNTI2NCAwIDcuMjcxLTIxLjk2OSAxMy4xNjUtNDkuMDY4IDEzLjE2NSIgZmlsbD0iI2MzYzJjMCIvPgogICA8cGF0aCBpZD0icGF0aDQ0MzEiIGQ9Im00NTEuMjggOTI1LjMzYy0yNi45MjIgMC00OC43NDctNS44NTUtNDguNzQ3LTEzLjA3OSAwLTIuMzM3NCAyLjI4NzEtNC41MzI4IDYuMjkyNS02LjQzMjYgMC4wNTY2IDAuMDMxMiAwLjExNTI1IDAuMDY0IDAuMTcxMzggMC4wOTM3LTMuOTEyNiAxLjg3NS02LjE0MzUgNC4wMzc2LTYuMTQzNSA2LjMzODkgMCA3LjE3NjIgMjEuNjgyIDEyLjk5MyA0OC40MjcgMTIuOTkzczQ4LjQyNy01LjgxNjQgNDguNDI3LTEyLjk5M2MwLTIuMzAxMi0yLjIzMTItNC40NjM5LTYuMTQzOC02LjMzODkgMC4wNTYzLTAuMDI5NyAwLjExNS0wLjA2MjUgMC4xNzEyNS0wLjA5MzcgNC4wMDUgMS44OTk5IDYuMjkyNSA0LjA5NTMgNi4yOTI1IDYuNDMyNiAwIDcuMjIzNi0yMS44MjQgMTMuMDc5LTQ4Ljc0NyAxMy4wNzkiIGZpbGw9IiNjMmMxYmYiLz4KICAgPHBhdGggaWQ9InBhdGg0NDMzIiBkPSJtNDUxLjI4IDkyNS4yNWMtMjYuNzQ1IDAtNDguNDI3LTUuODE2NC00OC40MjctMTIuOTkzIDAtMi4zMDEyIDIuMjMwOS00LjQ2MzkgNi4xNDM1LTYuMzM4OSAwLjA1OTEgMC4wMzI4IDAuMTEzNzUgMC4wNjI1IDAuMTcyODcgMC4wOTUzLTMuODIwOCAxLjg1MDEtNS45OTU2IDMuOTc4NS01Ljk5NTYgNi4yNDM2IDAgNy4xMjg5IDIxLjUzOCAxMi45MDYgNDguMTA2IDEyLjkwNnM0OC4xMDYtNS43Nzc0IDQ4LjEwNi0xMi45MDZjMC0yLjI2NTEtMi4xNzM4LTQuMzkzNS01Ljk5NS02LjI0MzYgMC4wNTg4LTAuMDMyOCAwLjExMzc1LTAuMDYyNSAwLjE3MjUtMC4wOTUzIDMuOTEyNSAxLjg3NSA2LjE0MzggNC4wMzc2IDYuMTQzOCA2LjMzODkgMCA3LjE3NjItMjEuNjgyIDEyLjk5My00OC40MjcgMTIuOTkzIiBmaWxsPSIjYzFjMGJlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQzNSIgZD0ibTQ1MS4yOCA5MjUuMTZjLTI2LjU2OCAwLTQ4LjEwNi01Ljc3NzQtNDguMTA2LTEyLjkwNiAwLTIuMjY1MSAyLjE3NDktNC4zOTM1IDUuOTk1Ni02LjI0MzYgMC4wNTYxIDAuMDI5NyAwLjExNjc1IDAuMDYyNSAwLjE3MzM4IDAuMDkzNy0zLjcyODEgMS44MjQ2LTUuODQ3OCAzLjkyMTQtNS44NDc4IDYuMTQ5OSAwIDcuMDgxIDIxLjM5NCAxMi44MiA0Ny43ODUgMTIuODJzNDcuNzg0LTUuNzM4OCA0Ny43ODQtMTIuODJjMC0yLjIyODUtMi4xMi00LjMyNTItNS44NDc1LTYuMTQ5OSAwLjA1NjItMC4wMzEyIDAuMTE3NS0wLjA2NCAwLjE3Mzc1LTAuMDkzNyAzLjgyMTIgMS44NTAxIDUuOTk1IDMuOTc4NSA1Ljk5NSA2LjI0MzYgMCA3LjEyODktMjEuNTM4IDEyLjkwNi00OC4xMDYgMTIuOTA2IiBmaWxsPSIjYzBiZmJkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQzNyIgZD0ibTQ1MS4yOCA5MjUuMDdjLTI2LjM5MSAwLTQ3Ljc4NS01LjczODgtNDcuNzg1LTEyLjgyIDAtMi4yMjg1IDIuMTE5Ni00LjMyNTIgNS44NDc4LTYuMTQ5OSAwLjA1ODUgMC4wMzEyIDAuMTE1MjUgMC4wNjI1IDAuMTc0MjUgMC4wOTM3LTMuNjM2MiAxLjgwMTItNS43MDE2IDMuODYzOC01LjcwMTYgNi4wNTYxIDAgNy4wMzI4IDIxLjI1MSAxMi43MzUgNDcuNDY0IDEyLjczNSAyNi4yMTQgMCA0Ny40NjQtNS43MDIxIDQ3LjQ2NC0xMi43MzUgMC0yLjE5MjQtMi4wNjYyLTQuMjU0OS01LjcwMTItNi4wNTYxIDAuMDU4OC0wLjAzMTIgMC4xMTUtMC4wNjI1IDAuMTczNzUtMC4wOTM3IDMuNzI3NSAxLjgyNDYgNS44NDc1IDMuOTIxNCA1Ljg0NzUgNi4xNDk5IDAgNy4wODEtMjEuMzk0IDEyLjgyLTQ3Ljc4NCAxMi44MiIgZmlsbD0iI2JmYmViYyIvPgogICA8cGF0aCBpZD0icGF0aDQ0MzkiIGQ9Im00NTEuMjggOTI0Ljk5Yy0yNi4yMTMgMC00Ny40NjQtNS43MDIxLTQ3LjQ2NC0xMi43MzUgMC0yLjE5MjQgMi4wNjU0LTQuMjU0OSA1LjcwMTYtNi4wNTYxIDAuMDU5MSAwLjAzMTIgMC4xMTY3NSAwLjA2MjUgMC4xNzUzNyAwLjA5MzctMy41NDYgMS43NzU5LTUuNTU2MiAzLjgwNjEtNS41NTYyIDUuOTYyNCAwIDYuOTg0OSAyMS4xMDcgMTIuNjQ5IDQ3LjE0NCAxMi42NDlzNDcuMTQzLTUuNjY0IDQ3LjE0My0xMi42NDljMC0yLjE1NjItMi4wMS00LjE4NjUtNS41NTYyLTUuOTYyNCAwLjA1ODgtMC4wMzEyIDAuMTE2MjUtMC4wNjI1IDAuMTc2MjUtMC4wOTM3IDMuNjM1IDEuODAxMiA1LjcwMTIgMy44NjM4IDUuNzAxMiA2LjA1NjEgMCA3LjAzMjgtMjEuMjUgMTIuNzM1LTQ3LjQ2NCAxMi43MzUiIGZpbGw9IiNiZWJkYmIiLz4KICAgPHBhdGggaWQ9InBhdGg0NDQxIiBkPSJtNDUxLjI4IDkyNC45Yy0yNi4wMzcgMC00Ny4xNDQtNS42NjQtNDcuMTQ0LTEyLjY0OSAwLTIuMTU2MiAyLjAxMDItNC4xODY1IDUuNTU2Mi01Ljk2MjQgMC4wNTkgMC4wMzEyIDAuMTE3NjMgMC4wNjI1IDAuMTc2NzUgMC4wOTM3LTMuNDU1NiAxLjc1MjQtNS40MTIxIDMuNzQ4NS01LjQxMjEgNS44Njg2IDAgNi45Mzc1IDIwLjk2MyAxMi41NjIgNDYuODIzIDEyLjU2MiAyNS44NTkgMCA0Ni44MjMtNS42MjUgNDYuODIzLTEyLjU2MiAwLTIuMTIwMS0xLjk1NzUtNC4xMTYyLTUuNDEyNS01Ljg2ODYgMC4wNTg4LTAuMDMxMiAwLjExNzUtMC4wNjI1IDAuMTc2MjUtMC4wOTM3IDMuNTQ2MiAxLjc3NTkgNS41NTYyIDMuODA2MSA1LjU1NjIgNS45NjI0IDAgNi45ODQ5LTIxLjEwNiAxMi42NDktNDcuMTQzIDEyLjY0OSIgZmlsbD0iI2JkYmNiYSIvPgogICA8cGF0aCBpZD0icGF0aDQ0NDMiIGQ9Im00NTEuMjggOTI0LjgxYy0yNS44NTkgMC00Ni44MjMtNS42MjUtNDYuODIzLTEyLjU2MiAwLTIuMTIwMSAxLjk1NjUtNC4xMTYyIDUuNDEyMS01Ljg2ODYgMC4wNTkgMC4wMzIzIDAuMTE4NjMgMC4wNjM1IDAuMTc3NzUgMC4wOTQ4LTMuMzY1OCAxLjcyNjUtNS4yNjkxIDMuNjg5OS01LjI2OTEgNS43NzM5IDAgNi44OTAxIDIwLjgyIDEyLjQ3NiA0Ni41MDIgMTIuNDc2czQ2LjUwMi01LjU4NiA0Ni41MDItMTIuNDc2YzAtMi4wODQtMS45MDM4LTQuMDQ3NC01LjI2ODgtNS43NzM5IDAuMDU4OC0wLjAzMTIgMC4xMTg3NS0wLjA2MjUgMC4xNzc1LTAuMDk0OCAzLjQ1NSAxLjc1MjQgNS40MTI1IDMuNzQ4NSA1LjQxMjUgNS44Njg2IDAgNi45Mzc1LTIwLjk2NCAxMi41NjItNDYuODIzIDEyLjU2MiIgZmlsbD0iI2JjYmJiOSIvPgogICA8cGF0aCBpZD0icGF0aDQ0NDUiIGQ9Im00NTEuMjggOTI0LjczYy0yNS42ODIgMC00Ni41MDItNS41ODYtNDYuNTAyLTEyLjQ3NiAwLTIuMDg0IDEuOTAzNC00LjA0NzQgNS4yNjkxLTUuNzczOSAwLjA1OTUgMC4wMzEyIDAuMTE5NjMgMC4wNjI1IDAuMTc5MTMgMC4wOTM3LTMuMjc3NCAxLjcwMTEtNS4xMjc0IDMuNjMyOC01LjEyNzQgNS42ODAxIDAgNi44NDIyIDIwLjY3NiAxMi4zOSA0Ni4xODEgMTIuMzlzNDYuMTgtNS41NDc5IDQ2LjE4LTEyLjM5YzAtMi4wNDc0LTEuODUtMy45NzktNS4xMjc1LTUuNjgwMSAwLjA2LTAuMDMxMiAwLjEyLTAuMDYyNSAwLjE4LTAuMDkzNyAzLjM2NSAxLjcyNjUgNS4yNjg4IDMuNjg5OSA1LjI2ODggNS43NzM5IDAgNi44OTAxLTIwLjgyIDEyLjQ3Ni00Ni41MDIgMTIuNDc2IiBmaWxsPSIjYmJiYWI4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ0NyIgZD0ibTQ1MS4yOCA5MjQuNjRjLTI1LjUwNSAwLTQ2LjE4MS01LjU0NzktNDYuMTgxLTEyLjM5IDAtMi4wNDc0IDEuODUtMy45NzkgNS4xMjc0LTUuNjgwMSAwLjA1OTEgMC4wMzEyIDAuMTIwNjIgMC4wNjM5IDAuMTc5NzUgMC4wOTUxLTMuMTg5IDEuNjc2Mi00Ljk4NjQgMy41NzM4LTQuOTg2NCA1LjU4NSAwIDYuNzk0OSAyMC41MzIgMTIuMzA0IDQ1Ljg2IDEyLjMwNHM0NS44Ni01LjUwODggNDUuODYtMTIuMzA0YzAtMi4wMTEyLTEuNzk3NS0zLjkwODgtNC45ODYyLTUuNTg1IDAuMDU4OC0wLjAzMTIgMC4xMi0wLjA2MzkgMC4xNzg3NS0wLjA5NTEgMy4yNzc1IDEuNzAxMSA1LjEyNzUgMy42MzI4IDUuMTI3NSA1LjY4MDEgMCA2Ljg0MjItMjAuNjc2IDEyLjM5LTQ2LjE4IDEyLjM5IiBmaWxsPSIjYmFiOWI3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ0OSIgZD0ibTQ1MS4yOCA5MjQuNTZjLTI1LjMyOCAwLTQ1Ljg2LTUuNTA4OC00NS44Ni0xMi4zMDQgMC0yLjAxMTIgMS43OTc0LTMuOTA4OCA0Ljk4NjQtNS41ODUgMC4wNjIgMC4wMzI3IDAuMTE5NjIgMC4wNjI1IDAuMTgxMTIgMC4wOTM3LTMuMTAwNiAxLjY1MTQtNC44NDY4IDMuNTE2MS00Ljg0NjggNS40OTEyIDAgNi43NDc1IDIwLjM4OSAxMi4yMTkgNDUuNTQgMTIuMjE5czQ1LjUzOS01LjQ3MTIgNDUuNTM5LTEyLjIxOWMwLTEuOTc1MS0xLjc0NjItMy44Mzk5LTQuODQ2Mi01LjQ5MTIgMC4wNjEzLTAuMDMxMiAwLjExODc1LTAuMDYxIDAuMTgxMjUtMC4wOTM3IDMuMTg4OCAxLjY3NjIgNC45ODYyIDMuNTczOCA0Ljk4NjIgNS41ODUgMCA2Ljc5NDktMjAuNTMyIDEyLjMwNC00NS44NiAxMi4zMDQiIGZpbGw9IiNiOWI4YjYiLz4KICAgPHBhdGggaWQ9InBhdGg0NDUxIiBkPSJtNDUxLjI4IDkyNC40N2MtMjUuMTUxIDAtNDUuNTQtNS40NzEyLTQ1LjU0LTEyLjIxOSAwLTEuOTc1MSAxLjc0NjEtMy44Mzk5IDQuODQ2OC01LjQ5MTIgMC4wNTk1IDAuMDMxMiAwLjEyMyAwLjA2MzUgMC4xODI2MyAwLjA5NTItMy4wMTMyIDEuNjI0NS00LjcwODUgMy40NTctNC43MDg1IDUuMzk2IDAgNi43MDAyIDIwLjI0NSAxMi4xMzIgNDUuMjE5IDEyLjEzMnM0NS4yMTgtNS40MzIxIDQ1LjIxOC0xMi4xMzJjMC0xLjkzOS0xLjY5NS0zLjc3MTUtNC43MDc1LTUuMzk2IDAuMDU4OC0wLjAzMTcgMC4xMjI1LTAuMDY0IDAuMTgyNS0wLjA5NTIgMy4xIDEuNjUxNCA0Ljg0NjIgMy41MTYxIDQuODQ2MiA1LjQ5MTIgMCA2Ljc0NzUtMjAuMzg4IDEyLjIxOS00NS41MzkgMTIuMjE5IiBmaWxsPSIjYjdiN2I1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ1MyIgZD0ibTQ1MS4yOCA5MjQuMzhjLTI0Ljk3NCAwLTQ1LjIxOS01LjQzMjEtNDUuMjE5LTEyLjEzMiAwLTEuOTM5IDEuNjk1Mi0zLjc3MTUgNC43MDg1LTUuMzk2IDAuMDYyIDAuMDMyMyAwLjEyMTUgMC4wNjIgMC4xODM2MiAwLjA5NDgtMi45MjcyIDEuNTk4Ni00LjU3MTQgMy4zOTg5LTQuNTcxNCA1LjMwMTIgMCA2LjY1MjQgMjAuMTAyIDEyLjA0NiA0NC44OTggMTIuMDQ2czQ0Ljg5OC01LjM5NCA0NC44OTgtMTIuMDQ2YzAtMS45MDI0LTEuNjQ1LTMuNzAyNi00LjU3MTItNS4zMDEyIDAuMDYxMy0wLjAzMjggMC4xMjEyNS0wLjA2MjUgMC4xODM3NS0wLjA5NDggMy4wMTI1IDEuNjI0NSA0LjcwNzUgMy40NTcgNC43MDc1IDUuMzk2IDAgNi43MDAyLTIwLjI0NCAxMi4xMzItNDUuMjE4IDEyLjEzMiIgZmlsbD0iI2I2YjZiNCIvPgogICA8cGF0aCBpZD0icGF0aDQ0NTUiIGQ9Im00NTEuMjggOTI0LjNjLTI0Ljc5NiAwLTQ0Ljg5OC01LjM5NC00NC44OTgtMTIuMDQ2IDAtMS45MDI0IDEuNjQ0MS0zLjcwMjYgNC41NzE0LTUuMzAxMiAwLjA2MiAwLjAzMTIgMC4xMjI1IDAuMDYyNSAwLjE4NSAwLjA5MzctMi44NDI4IDEuNTc1MS00LjQzNTUgMy4zNDEyLTQuNDM1NSA1LjIwNzUgMCA2LjYwNSAxOS45NTggMTEuOTYgNDQuNTc3IDExLjk2czQ0LjU3Ny01LjM1NSA0NC41NzctMTEuOTZjMC0xLjg2NjItMS41OTI1LTMuNjMyNC00LjQzNS01LjIwNzUgMC4wNjI1LTAuMDMxMiAwLjEyMjUtMC4wNjI1IDAuMTg1LTAuMDkzNyAyLjkyNjIgMS41OTg2IDQuNTcxMiAzLjM5ODkgNC41NzEyIDUuMzAxMiAwIDYuNjUyNC0yMC4xMDIgMTIuMDQ2LTQ0Ljg5OCAxMi4wNDYiIGZpbGw9IiNiNWI0YjMiLz4KICAgPHBhdGggaWQ9InBhdGg0NDU3IiBkPSJtNDUxLjI4IDkyNC4yMWMtMjQuNjE5IDAtNDQuNTc3LTUuMzU1LTQ0LjU3Ny0xMS45NiAwLTEuODY2MiAxLjU5MjgtMy42MzI0IDQuNDM1NS01LjIwNzUgMC4wNTk2IDAuMDMxMiAwLjEyNiAwLjA2NDkgMC4xODYgMC4wOTUyLTIuNzU3MiAxLjU0ODItNC4zMDEyIDMuMjgyMS00LjMwMTIgNS4xMTIyIDAgNi41NTc2IDE5LjgxNCAxMS44NzQgNDQuMjU3IDExLjg3NCAyNC40NDIgMCA0NC4yNTctNS4zMTU5IDQ0LjI1Ny0xMS44NzQgMC0xLjgzMDEtMS41NDM4LTMuNTY0LTQuMzAxMi01LjExMjIgMC4wNi0wLjAzMDQgMC4xMjYyNS0wLjA2NCAwLjE4NjI1LTAuMDk1MiAyLjg0MjUgMS41NzUxIDQuNDM1IDMuMzQxMiA0LjQzNSA1LjIwNzUgMCA2LjYwNS0xOS45NTggMTEuOTYtNDQuNTc3IDExLjk2IiBmaWxsPSIjYjRiM2IyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ1OSIgZD0ibTQ1MS4yOCA5MjQuMTNjLTI0LjQ0MiAwLTQ0LjI1Ny01LjMxNTktNDQuMjU3LTExLjg3NCAwLTEuODMwMSAxLjU0NC0zLjU2NCA0LjMwMTItNS4xMTIyIDAuMDY0NSAwLjAzMzYgMC4xMjI2MyAwLjA2MjUgMC4xODcgMC4wOTQ2LTIuNjcyOCAxLjUyMjUtNC4xNjc0IDMuMjIzNi00LjE2NzQgNS4wMTc2IDAgNi41MDk4IDE5LjY3MSAxMS43ODggNDMuOTM2IDExLjc4OHM0My45MzYtNS4yNzc4IDQzLjkzNi0xMS43ODhjMC0xLjc5NC0xLjQ5MzgtMy40OTUxLTQuMTY3NS01LjAxNzYgMC4wNjUtMC4wMzIxIDAuMTIyNS0wLjA2MSAwLjE4NzUtMC4wOTQ2IDIuNzU3NSAxLjU0ODIgNC4zMDEyIDMuMjgyMSA0LjMwMTIgNS4xMTIyIDAgNi41NTc2LTE5LjgxNCAxMS44NzQtNDQuMjU3IDExLjg3NCIgZmlsbD0iI2IzYjJiMSIvPgogICA8cGF0aCBpZD0icGF0aDQ0NjEiIGQ9Im00NTEuMjggOTI0LjA0Yy0yNC4yNjUgMC00My45MzYtNS4yNzc4LTQzLjkzNi0xMS43ODggMC0xLjc5NCAxLjQ5NDYtMy40OTUxIDQuMTY3NC01LjAxNzYgMC4wNjI1IDAuMDMxMiAwLjEyNjUgMC4wNjQgMC4xODkgMC4wOTUzLTIuNTkwNCAxLjQ5NjEtNC4wMzUxIDMuMTYzNi00LjAzNTEgNC45MjI0IDAgNi40NjI0IDE5LjUyNyAxMS43MDEgNDMuNjE1IDExLjcwMXM0My42MTQtNS4yMzg4IDQzLjYxNC0xMS43MDFjMC0xLjc1ODgtMS40NDUtMy40MjYyLTQuMDM1LTQuOTIyNCAwLjA2MjUtMC4wMzEyIDAuMTI2MjUtMC4wNjQgMC4xODg3NS0wLjA5NTMgMi42NzM4IDEuNTIyNSA0LjE2NzUgMy4yMjM2IDQuMTY3NSA1LjAxNzYgMCA2LjUwOTgtMTkuNjcgMTEuNzg4LTQzLjkzNiAxMS43ODgiIGZpbGw9IiNiMWIxYjAiLz4KICAgPHBhdGggaWQ9InBhdGg0NDYzIiBkPSJtNDUxLjI4IDkyMy45NWMtMjQuMDg4IDAtNDMuNjE1LTUuMjM4OC00My42MTUtMTEuNzAxIDAtMS43NTg4IDEuNDQ0OC0zLjQyNjIgNC4wMzUxLTQuOTIyNCAwLjA2MiAwLjAzMTIgMC4xMjc1IDAuMDYzNSAwLjE5IDAuMDk0Ny0yLjUwNzQgMS40NzAyLTMuOTA0OSAzLjEwNS0zLjkwNDkgNC44Mjc2IDAgNi40MTUgMTkuMzg0IDExLjYxNiA0My4yOTQgMTEuNjE2IDIzLjkxMSAwIDQzLjI5NC01LjIwMTIgNDMuMjk0LTExLjYxNiAwLTEuNzIyNi0xLjM5NzUtMy4zNTc0LTMuOTA1LTQuODI3NiAwLjA2MjUtMC4wMzEyIDAuMTI3NS0wLjA2MzUgMC4xOS0wLjA5NDcgMi41OSAxLjQ5NjEgNC4wMzUgMy4xNjM2IDQuMDM1IDQuOTIyNCAwIDYuNDYyNC0xOS41MjYgMTEuNzAxLTQzLjYxNCAxMS43MDEiIGZpbGw9IiNiMGIwYWYiLz4KICAgPHBhdGggaWQ9InBhdGg0NDY1IiBkPSJtNDUxLjI4IDkyMy44N2MtMjMuOTExIDAtNDMuMjk0LTUuMjAxMi00My4yOTQtMTEuNjE2IDAtMS43MjI2IDEuMzk3NS0zLjM1NzQgMy45MDQ5LTQuODI3NiAwLjA2NDkgMC4wMzI3IDAuMTI1ODggMC4wNjI1IDAuMTkwODggMC4wOTUyLTIuNDI0OCAxLjQ0MzgtMy43NzQ5IDMuMDQ1OS0zLjc3NDkgNC43MzI0IDAgNi4zNjc2IDE5LjI0IDExLjUzIDQyLjk3NCAxMS41MyAyMy43MzMgMCA0Mi45NzMtNS4xNjIyIDQyLjk3My0xMS41MyAwLTEuNjg2NS0xLjM1LTMuMjg4Ni0zLjc3NS00LjczMjQgMC4wNjUtMC4wMzI3IDAuMTI2MjUtMC4wNjI1IDAuMTkxMjUtMC4wOTUyIDIuNTA3NSAxLjQ3MDIgMy45MDUgMy4xMDUgMy45MDUgNC44Mjc2IDAgNi40MTUtMTkuMzg0IDExLjYxNi00My4yOTQgMTEuNjE2IiBmaWxsPSIjYWZhZmFkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ2NyIgZD0ibTQ1MS4yOCA5MjMuNzhjLTIzLjczMyAwLTQyLjk3NC01LjE2MjItNDIuOTc0LTExLjUzIDAtMS42ODY1IDEuMzUwMS0zLjI4ODYgMy43NzQ5LTQuNzMyNCAwLjA2MyAwLjAzMTIgMC4xMzAzNyAwLjA2NDkgMC4xOTI4NyAwLjA5NjEtMi4zNDQyIDEuNDE2LTMuNjQ3IDIuOTg2NC0zLjY0NyA0LjYzNjIgMCA2LjMxOTkgMTkuMDk2IDExLjQ0NCA0Mi42NTMgMTEuNDQ0IDIzLjU1NiAwIDQyLjY1My01LjEyNCA0Mi42NTMtMTEuNDQ0IDAtMS42NDk5LTEuMzAyNS0zLjIyMDItMy42NDc1LTQuNjM2MiAwLjA2MjUtMC4wMzEyIDAuMTMtMC4wNjQ5IDAuMTkyNS0wLjA5NjEgMi40MjUgMS40NDM4IDMuNzc1IDMuMDQ1OSAzLjc3NSA0LjczMjQgMCA2LjM2NzYtMTkuMjQgMTEuNTMtNDIuOTczIDExLjUzIiBmaWxsPSIjYWVhZWFjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ2OSIgZD0ibTQ1MS4yOCA5MjMuN2MtMjMuNTU3IDAtNDIuNjUzLTUuMTI0LTQyLjY1My0xMS40NDQgMC0xLjY0OTkgMS4zMDI4LTMuMjIwMiAzLjY0Ny00LjYzNjIgMC4wNjU0IDAuMDMxMiAwLjEyODg4IDAuMDYyNSAwLjE5NDM4IDAuMDk0Ny0yLjI2NDggMS4zOTE2LTMuNTIwNSAyLjkyNjItMy41MjA1IDQuNTQxNSAwIDYuMjcyNSAxOC45NTMgMTEuMzU3IDQyLjMzMiAxMS4zNTdzNDIuMzMyLTUuMDg0OSA0Mi4zMzItMTEuMzU3YzAtMS42MTUyLTEuMjU2Mi0zLjE0OTktMy41Mi00LjU0MTUgMC4wNjUtMC4wMzIyIDAuMTI4NzUtMC4wNjM1IDAuMTkzNzUtMC4wOTQ3IDIuMzQ1IDEuNDE2IDMuNjQ3NSAyLjk4NjQgMy42NDc1IDQuNjM2MiAwIDYuMzE5OS0xOS4wOTcgMTEuNDQ0LTQyLjY1MyAxMS40NDQiIGZpbGw9IiNhZGFjYWIiLz4KICAgPHBhdGggaWQ9InBhdGg0NDcxIiBkPSJtNDUxLjI4IDkyMy42MWMtMjMuMzc5IDAtNDIuMzMyLTUuMDg0OS00Mi4zMzItMTEuMzU3IDAtMS42MTUyIDEuMjU1OC0zLjE0OTkgMy41MjA1LTQuNTQxNSAwLjA2NDkgMC4wMzE3IDAuMTI5ODcgMC4wNjQgMC4xOTUyNSAwLjA5NTItMi4xODUgMS4zNjUyLTMuMzk1IDIuODY3Ni0zLjM5NSA0LjQ0NjIgMCA2LjIyNTEgMTguODA5IDExLjI3MSA0Mi4wMTEgMTEuMjcxczQyLjAxLTUuMDQ2NCA0Mi4wMS0xMS4yNzFjMC0xLjU3ODYtMS4yMDg4LTMuMDgxLTMuMzk1LTQuNDQ2MiAwLjA2NjItMC4wMzEyIDAuMTMxMjUtMC4wNjM1IDAuMTk2MjUtMC4wOTUyIDIuMjYzOCAxLjM5MTYgMy41MiAyLjkyNjIgMy41MiA0LjU0MTUgMCA2LjI3MjUtMTguOTUyIDExLjM1Ny00Mi4zMzIgMTEuMzU3IiBmaWxsPSIjYWNhYmFhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ3MyIgZD0ibTQ1MS4yOCA5MjMuNTJjLTIzLjIwMiAwLTQyLjAxMS01LjA0NjQtNDIuMDExLTExLjI3MSAwLTEuNTc4NiAxLjIxLTMuMDgxIDMuMzk1LTQuNDQ2MiAwLjA2NTUgMC4wMzI2IDAuMTMxMzcgMC4wNjM5IDAuMTk2NzUgMC4wOTYxLTIuMTA2NCAxLjMzNzQtMy4yNzEgMi44MDc2LTMuMjcxIDQuMzUwMSAwIDYuMTc3MiAxOC42NjYgMTEuMTg1IDQxLjY5IDExLjE4NSAyMy4wMjUgMCA0MS42OS01LjAwNzggNDEuNjktMTEuMTg1IDAtMS41NDI1LTEuMTY1LTMuMDEyOC0zLjI3MTItNC4zNTAxIDAuMDY1LTAuMDMyMiAwLjEzMTI1LTAuMDYzNSAwLjE5NjI1LTAuMDk2MSAyLjE4NjIgMS4zNjUyIDMuMzk1IDIuODY3NiAzLjM5NSA0LjQ0NjIgMCA2LjIyNTEtMTguODA5IDExLjI3MS00Mi4wMSAxMS4yNzEiIGZpbGw9IiNhYWFhYTkiLz4KICAgPHBhdGggaWQ9InBhdGg0NDc1IiBkPSJtNDUxLjI4IDkyMy40NGMtMjMuMDI1IDAtNDEuNjktNS4wMDc4LTQxLjY5LTExLjE4NSAwLTEuNTQyNSAxLjE2NDYtMy4wMTI4IDMuMjcxLTQuMzUwMSAwLjA2NTUgMC4wMzEyIDAuMTMzMzcgMC4wNjQgMC4xOTg3NSAwLjA5NTMtMi4wMjkyIDEuMzExLTMuMTQ4OSAyLjc0NzUtMy4xNDg5IDQuMjU0OSAwIDYuMTI5OSAxOC41MjIgMTEuMDk5IDQxLjM3IDExLjA5OXM0MS4zNjktNC45Njg4IDQxLjM2OS0xMS4wOTljMC0xLjUwNzQtMS4xMi0yLjk0MzktMy4xNDg4LTQuMjU0OSAwLjA2NjItMC4wMzEyIDAuMTMzNzUtMC4wNjQgMC4xOTg3NS0wLjA5NTMgMi4xMDYyIDEuMzM3NCAzLjI3MTIgMi44MDc2IDMuMjcxMiA0LjM1MDEgMCA2LjE3NzItMTguNjY2IDExLjE4NS00MS42OSAxMS4xODUiIGZpbGw9IiNhOWE5YTgiLz4KICAgPHBhdGggaWQ9InBhdGg0NDc3IiBkPSJtNDUxLjI4IDkyMy4zNWMtMjIuODQ4IDAtNDEuMzctNC45Njg4LTQxLjM3LTExLjA5OSAwLTEuNTA3NCAxLjExOTYtMi45NDM5IDMuMTQ4OS00LjI1NDkgMC4wNjc5IDAuMDMyMiAwLjEzMTg3IDAuMDYzNSAwLjE5OTc1IDAuMDk2MS0xLjk1MjEgMS4yODIyLTMuMDI3OSAyLjY4NzUtMy4wMjc5IDQuMTU4OCAwIDYuMDgyNSAxOC4zNzggMTEuMDE0IDQxLjA0OSAxMS4wMTQgMjIuNjcgMCA0MS4wNDktNC45MzExIDQxLjA0OS0xMS4wMTQgMC0xLjQ3MTItMS4wNzYyLTIuODc2NS0zLjAyODgtNC4xNTg4IDAuMDY4Ny0wLjAzMjYgMC4xMzI1LTAuMDYzOSAwLjItMC4wOTYxIDIuMDI4OCAxLjMxMSAzLjE0ODggMi43NDc1IDMuMTQ4OCA0LjI1NDkgMCA2LjEyOTktMTguNTIyIDExLjA5OS00MS4zNjkgMTEuMDk5IiBmaWxsPSIjYThhOGE3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDQ3OSIgZD0ibTQ1MS4yOCA5MjMuMjdjLTIyLjY3MSAwLTQxLjA0OS00LjkzMTEtNDEuMDQ5LTExLjAxNCAwLTEuNDcxMiAxLjA3NTgtMi44NzY1IDMuMDI3OS00LjE1ODggMC4wNjU5IDAuMDMxMiAwLjEzMjc1IDAuMDYyNSAwLjE5ODI1IDAuMDkzNy0xLjg3NiAxLjI1NjQtMi45MDkyIDIuNjI2LTIuOTA5MiA0LjA2MTEgMCA2LjA0MTUgMTguMjM2IDEwLjkzNSA0MC43MzYgMTAuOTM1IDIyLjQ4OCAwIDQwLjcyNS00Ljg5MzUgNDAuNzI1LTEwLjkzNSAwLTEuNDM1MS0xLjAzMjUtMi44MDQ4LTIuOTA4OC00LjA2MDEgMC4wNjYyLTAuMDMxMiAwLjEzNS0wLjA2MzUgMC4yLTAuMDk0NyAxLjk1MjUgMS4yODIyIDMuMDI4OCAyLjY4NzUgMy4wMjg4IDQuMTU4OCAwIDYuMDgyNS0xOC4zNzkgMTEuMDE0LTQxLjA0OSAxMS4wMTQiIGZpbGw9IiNhN2E2YTUiLz4KICAgPHBhdGggaWQ9InBhdGg0NDgxIiBkPSJtNDU4IDkxMi4yNWMwIDAuOTk3NS0zLjAxMTggMS44MDUxLTYuNzI3IDEuODA1MS0zLjcxNTQgMC02LjcyNzEtMC44MDc2Mi02LjcyNzEtMS44MDUxIDAtMC45OTYxMiAzLjAxMTgtMS44MDUxIDYuNzI3MS0xLjgwNTEgMy43MTUyIDAgNi43MjcgMC44MDkgNi43MjcgMS44MDUxIiBmaWxsPSIjMTAwZjBkIi8+CiAgPC9nPgogIDxnIGlkPSJnNDU2NSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ1NjciIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ1NjktNykiPgogICAgPHBhdGggaWQ9InBhdGg0NTgxIiBkPSJtOTM2LjI4IDE5ODYuNmMwIDEwNS4yNy04Ni4xMjEgMTkxLjM5LTE5MS4zOSAxOTEuMzloLTczLjQzOGMtMTAuNTUxIDAtMjAuOTA2LTAuODYtMzEuMDA0LTIuNTN2MzUxNGMwIDMwLjQ2LTI0LjY5NSA1NS4xNi01NS4xNiA1NS4xNnMtNTUuMTYtMjQuNy01NS4xNi01NS4xNnYtMzU3NC4yYy0zMS4wNjctMzQuMDUtNTAuMDY3LTc5LjI3LTUwLjA2Ny0xMjguNzJ2MzY3NS4zYzAgMTMuNDYgMS40MTEgMjYuNjIgNC4wOSAzOS4zMSAxOC4yNjIgODYuNTcgOTUuNTA0IDE1Mi4wNyAxODcuMyAxNTIuMDdoNzMuNDM4YzEwNS4yNiAwIDE5MS4zOS04Ni4xMiAxOTEuMzktMTkxLjM4di0zNjc1LjMiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ0NTczKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNDU4MyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ1ODUiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ1ODctMCkiPgogICAgPHBhdGggaWQ9InBhdGg0NTk3IiBkPSJtNDgwLjA3IDE0MDAuN3Y1ODUuOTVjMCA0OS40NSAxOSA5NC42NyA1MC4wNjcgMTI4Ljcydi01MjguNTJjMC0zMC40NyAyNC42OTUtNTUuMTYgNTUuMTYtNTUuMTZzNTUuMTYgMjQuNjkgNTUuMTYgNTUuMTZ2NTg4LjY2YzEwLjA5OCAxLjY3IDIwLjQ1MyAyLjUzIDMxLjAwNCAyLjUzaDczLjQzOGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOXYtNTg1LjkzYy03MC4wODIgMjkuNzYtMTQ3LjE3IDQ2LjIyLTIyOC4xIDQ2LjIyLTgwLjkzNCAwLTE1OC4wMy0xNi40Ni0yMjguMTEtNDYuMjIiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ0NTkxKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNDU5OSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ2MDEiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ2MDMtMSkiPgogICAgPHBhdGggaWQ9InBhdGg0NjEzIiBkPSJtMTI5Mi40IDg2Mi43NWMwLTMyMi42NC0yNjEuNTQtNTg0LjE4LTU4NC4xNy01ODQuMThzLTU4NC4xOCAyNjEuNTQtNTg0LjE4IDU4NC4xOGMwIDMyMi42MyAyNjEuNTUgNTg0LjE4IDU4NC4xOCA1ODQuMThzNTg0LjE3LTI2MS41NSA1ODQuMTctNTg0LjE4IiBmaWxsPSJ1cmwoI3JhZGlhbEdyYWRpZW50NDYzOS0yKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNDYxNSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ2MTciIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ2MTktMikiPgogICAgPHBhdGggaWQ9InBhdGg0NjI5IiBkPSJtMTA1MC43IDExNzIuM3YxLjQzLTAuNzEtMC43MiIgZmlsbD0idXJsKCNyYWRpYWxHcmFkaWVudDQ2MzktMikiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzQ2MzEiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IDM2Mi43NiA5MzYpIj4KICAgPGcgaWQ9Imc0NjMzIiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg0NjM1LTEpIj4KICAgIDxwYXRoIGlkPSJwYXRoNDY0NSIgZD0ibTk2OC4zOCAxMDMxLjhjNTEuMzA1IDM3Ljk3IDgyLjI5NSA4Ny4yOSA4Mi4yOTUgMTQxLjIxdi0wLjcyYy0wLjI4LTUzLjYzLTMxLjIxLTEwMi42OS04Mi4yOTUtMTQwLjQ5bS01NjguMzQgNDYuM2MtMjIuMDA4IDI4LjY3LTM0LjM1OSA2MC44OC0zNC4zNTkgOTQuOTEgMC0zNC4wMyAxMi4zNTEtNjYuMjQgMzQuMzU5LTk0LjkxbTY1MC42MyA5NC45MWMwIDE1LjY5LTIuNjMgMzEtNy42MSA0NS43NSA0LjkxLTE0LjUzIDcuNTMtMjkuNiA3LjYxLTQ1LjA0di0wLjcxIiBmaWxsPSJ1cmwoI3JhZGlhbEdyYWRpZW50NDYzOS0yKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNDY0NyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ2NDkiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ2NTEtOCkiPgogICAgPGcgaWQ9Imc0NjU1IiB0cmFuc2Zvcm09Im1hdHJpeCg2OTYuMiwwLDAsNDUzLjIsMzU5LjksOTQ1LjkpIj4KICAgICA8aW1hZ2UgaWQ9ImltYWdlNDY1NyIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFKRUFBQUJlQ0FZQUFBQWpadlpDQUFBQUJITkNTVlFJQ0FnSWZBaGtpQUFBRnNoSlJFRlVlSnp0WGRlTzdMaXVKUTMvLzkvZWx3dVVlUjRrU290SnRpdDE5OHdRMkx0dFdWbExUQXJGLy9mL0Q2R2ZJUDZSVXYvNTlBT2p1Ui9IOFoyUzJLR21iQ3pFNDAvM3lMdVIvT0g2Q3RiM1lsbnllVlR0MzhJUWJkcVlzNEdUQ1I3VC90Y0huRDJRMzA0eGYzbDVFQ1UreTlWMkNORVh4bmQvZkJLcERBK2pNWko4ZDRsV25YVEtuUlpwYjdTMUF0enJvQmc1blh6VzhxdjJKT25MTExOSitUN2FqK01OT1lkMitnQ0ozMWlLUnJuQWJUc3I3Qkt4QTUrY3plWW53WEs5bkpQeS9lZXJJbU9VdHdMWkhRQ2UwLzU0QlVTaHZ0ekRIUEpOaHlTellndVJKdm5PNnh5Qzc0TEpEK2FuOUMxSnlycVZYUHZuYXYyOHlMb0lFRWsrUE5rbHozT2lqZktCU2NOTWdQdk9yaE5PQm50MGJsSHZxM3JQUi9YTkM1bmY1WFFCbUc2R2h2eDh2eVhwUXhXZTA2SHVjeUxsR2tjdmxLRXhpbTRHZVM0Z3ZyU2lTRUp1NEY4YzNkNlp2TEVQK2pIQzVzbXprM2JWQ0JFcSt5Mno2RXhlbWJKK0QwejdjWG5RR0xpS3RQZU55WnJreFd4aG9WUmNsVnhEMHNkMUdrZXJ3ZnE0bFVaMm9KN0J6Ukw1bmx0RG1FL0hSeUlkTUs1T2N1cDlKdk5kbUs1VS9pSW5RZ0QxWnhLaWgxZ2dpYmoyYVRoRE9FNUxtbm41Tk81eHBpa0FlWWRDUjc4NXY1ZXA0aXI0VUlrcnA2aFdZbEJjR09KZ2dBZVpSazIxVG9RZGk3b09nb25Jc3IzTnlWbWhEakt5SGJDU1htZVNEUlgzaTJBNng4aUxJSGlib1hlRGUyWDZ6Q3J0Z2FxSGhtSEZRUjB4K3BJZjA1aDV6b20yellvdG5QeENYUytTV1JGMUpCNGFEeXAzU0J6RndZRmVGU3MxbUl5cC9jTTZFVkp1OHA5eG5xdmZDdTRqUHR4eG1JTXN3SkF4R083WHBZNnptUGNqczZhT2pnWUZDNE5zWko0WmIxMU00Vmhta2trZzdhZ1Qyemkrd1Q1K2FKUVQ3U0hlT1VDOVQrZFZPdlU5bGZYUTlLY0ZMTDRSaGM3MDhYM3lNWTQ5clRJVWhyVDRqRG9UdEhWL0hJOGV1VHYxekVCRFpRYUhBY1NvVGhRWWduS3dsYXk2UWhJYmpybHRNTE13VlRHWUFUUnY1MUN2MVdOcHVaM3B1T2xFeXZxLzROQ2piS2N6VldNNHVOdlJPUkU2aFkrakFVazVUdVVBMWZBRGRCemxjbWppRzdGeXpJUWpYeWc4N2R5Q1k2M3E5UEdGMjR0MFZvOVEvd1huemJpUUFlbVI1NW5GOVhsbDRzc3IxRmdQWTlVeDdZZlFaRlBiUmdacFhuUXBOMk5LQU1iMlBWdEVaUURNNEY3RnpHQVhhTVJZd2RIdTZCdGZwK3QxTzEyZmsvQlF0SjM3eElXNE9ERkxvR2h5ZGxhYkV6a2ROMjBCbGlHUUFTSGpHNGltWWE1TFY4aUFVNFMyZXd2dmNHR1l4bk9vUEM5T3l4bXQ5S1grSXBMa3FhTFdBdkdEbkdaWFRNSlV5VTdVZzZDeTBHUUNBdWtSakNaUDdzNUc5T01vcThKM0pyQzh5RnBoUWxNQlI2Nmw5ZGhzclp1M3dKbjloVmx2R1k3TTZrRUR6RnRQVU16dk5QVHpWRU02Y3B4MUhYTUc1VjBvUkN3Q2JxTStzUTV1WVV4V2dkYXhWVWFCM0VoQmJCeW5UdGNWb3YweEhFdVpHUTRWUTlGR0NrWW44bHc4NFlNWVVDdkVWaFJ4aGlMTnA3ZERZcml0SktTL09TamZvVlJHTCtKQXFKOVFtYTJpNlprbWgyRHVjeCs0SDQ0RmN3TXdkcDh1RjRIVVkzTHhsT0ZnSFVSbzk5dDhlR01TMEZPNDZ6SGlXSVFXZ2x5ejFRWFFKMXNYd2RBWXp6WkhKMFFRcDNwbTFZbC9ncDZ2NTlCbnN3OCtmNjlHZGhZMHgxREcvM05vMm5jR1FJMzR5dUZGaUZnYXVGU2RJUUp4WnN4eU4zcm9zZFpsamxFRFpLY2NKNXRYblAzMm9GNUgzMEViQzRUNVRLdkIrSjNhMEpxeXR0Ukt1UGZyWlgwWDFJK3d1MEludndNZyt1N1E1QmVIQzFFQXRUaHRlK3hHVnJSNEhLa3daV21aS3hBd25pSVlnU0pabklUcENBZmZ5VEVTWk9SWjJYWHdjQW5BOTVKY3JwUFJaay9pQ1htM3daRXNrZ1krb0lEUnRNTVZBMGtSVkdFZHplZEZOTlk4aFdsL2FFYlNRYUxtaitjQ0RBQUo2MmhJR3pHYWdxWU9YZUhPOUlSa3kwTGVwWGRBOEpOaTdpcUhXYVhoNG10Zzl6WXRrM1YwampGMjMrVnd6QWpHMVhrR1V1VzArNm5iQXV6dy9ISTM4N2Z4U2lKMjUrRkJ4Q3hUSndvY3E0dWhMVlljaFRWNkJxb096eUdnSmtXVnpzY2x4K1crSmZKUUQ3eFM1cFYyZVRUTThEaldUbzNRNUtnRXU3RWIxcUxwTGxCNlI3Z0YzLzRnZG9tNi9GTmdkVE1KbmRDR01uYkliRmVJWWVWL0xPb0xOYkNtV3p0cWNEQ3Z2NmZmZnBJaGxSV285UjZpeW9CWXBPV05waTlISlJJQVlQeVZtZmtaVmdrbmdJQ3VwRHBTRjJmcUloaXBsUkVOajNWTExBZDNyN1VUWlFOY1h1RUQ1cXYyS2VsRXdJcGtyV0g0MzVLY2JwVDZMY3IxSy9YSTJ5amxOMVI4KzErZWNjV0QwNnlQT2tBTlo1TE03U1BJNWxpeE9rWEpmb2dRUFhxaEcyYWtPWGZnS0xDVUxZSDZaTnRqMFQvOVJqYmlXc0Y5OXRzL2hlNjJVZHhUSXU3OGhCM2NDWExSY2RLdElVeGRva3l1WXhJZDdYRS8yQUhHUEt0dUpFUHg1b0UwN3FoR3J0SUJCMERmc2c3aEJFSzQvREpibENUOUxaem1zMVN1bjJWOUZNM2RrR3dhWEtpK0FMQ1FBM0hqWDlQNUI5KzhXMkFzd0RKazN0WXFJQktCMTVsaDI2MVl6bXAwbFFuR0kyUHJtZkxYeTA5QmgzbThqUkY5aXFPOUMrVFJXbTJQN2ZrZ0dGaS9NSnFTWnhMK1VSdytKdGhFdjRtV2NRQ3dpUFpqZ0FlVUw1YmhxVFpjek5RRlFFZmRVZ3ZFcnI3UzQ5VUQrRGo1dnRZR1Y5Ky9SVmZMdjJLSm5hV3R5NTU5bmFWaEU2ODlRSDFDbGJxcHpVUWl1UEcvU2FDNWlpL2RQUlpPbk5JQUZnbWJSZjc1RFFIbDJhVXF5U3F0WlZacTBRbTJzWFZIVHdYK0xuZytKUmF2MXNQR2l3YUQ0OUltZk1WNU9xY2dWYWt6ZGFXL0t6ZEppaDMxQ2ZYcUl1NDRCZ05zbTlLRTJwb1o5ZkszK1pmUnRsZGR4dnRkdEg2eXdhRGFCa3JvQ0Q5YkVHU05FamdIaWo2WUs0UDRUSnFyK1oxeDBxb09LMUlSTnZQMUhOMXkrUXBjTTQxSW44aERSTW1NTm5RaHphb3I1bVluVDN1UW8zTWloaE1ackVEZU9BQndtdjJUazh4OVpsN2I5L0NlRGNNbmNTQkM2RHhDUHE3QkZ3YkxnL1JxdW51MHlxOTNkb2h6bGlacmR5S0dURmlXczllRjhKMEhRNWd1SmdXWHpDUkVFMWlIZ25CbXNZdHlseTVsV1BVZFJiWkRua0VzYmVYR0tUc3IvS3p4alp4L3J4bGZoZEladnVIWFQ0bXZxM1RHL2M3cTU4WGYxWWxnRnpyelZEeTVoemlnd2Q0UUdWeEdwdFFpdGM0VVFNSm15UUszQmJRTU45Q1BiTVBDYlJnRDlUbDdqU0d0ME9YR0s2TVBWUG45dEdKOWo2cXAxV2lsRThiSm1jZXMrZ3c1Q25JMW9XR2R5MkcramJIcDRHbmJRWVQyQjFsTFRQV3lBYUNOcHN5Q3ZVQThFdmpheDRhZDg0RFcwUHFtajg4QjQxMXVwOCtjOXovTFZMM1I2MFpFb05aNm0ycW1VMWZpQmlZdnhrWldUTHZRUnNNUE1KUXExVlNZeGw0U2JpSktXSWdQdG9jbm9RcDVjM0xXdmJGdFZOUWJLdGZCKytoOWwxWVJmVkprWm4wenkreUs5K3B1b3BCYklTR0UyaGdMVDF5TU1nUUUwRVRUL2xESFViYVUwWjk1ckpOeGQ0UFBUSG53Sk4zeDVpcGw2bWtiK1NqMEtkL2dMSWYza0ZWV1g2ZjNBZko2VHFneWVHWGI2MUg0NW5YSG5nZXUxdXNPUjF3aUFZbWpXMmYzWS9JY3loVXZtZXRmeXBFR0I5bFNLeUZXM05wanRpRyt3UkEzT2JkVks4cXJiditXbUh5bW5DeE5JV3JTaXorelBsVC8wSm96MmREdTEySHBlNDA2cUxqZzFsUGYxajNXaWo1TnFLanNBTUtkY0VSakdXUzlpSXB4T291RHRSNjJzSzVyR0hKMGNkTDNPd0I3SjEwdDUxcThlZ3JWMXFtUTluTVN2aXg3T2lZRisxOGZVUmt5aTdmY1YvSFZsQit5Ykp2akRzVXdFVkdYbFY1MFJRWFZWZFlkTTRtTlFtN2xDSThDbFF1elYxeVRTYjArUmlzeENiYmtuWVZXZlRWNXJDWEJURk54dHl3ZW1QR3pRazZxVEtDMUkwTzQzc3BiRUxHcy8zVVh1RjFKNzNEQ3VLNXdBemZmT1l2T0d1VmMwSjFZQXR5cm1NdDh2a09TUFBrb21iaFMwRTJPUHI2SkN4TXNJN2ZHSlAydTIwYlUzUGZkTDJUTWZlNm5QUmcyNGtzZjJHRVY4VWFveVRQNU0wMjRQak14d1lCRU0zT2Njc2Z1SFNNSjFQUnM2T2VCeUpXWS9HV1VIdFd3SlBBL0pDUWZMQ0VNK3pLT2xTMTI3b2hVWFdxTUdhdGtPaUJkejdNemxmMEEwV1ZQd3ZZODFEK0FPeFdMcG01cTRRSG9rbWFXRkVYaXRBS0xGUFAvMEhuWDZWWGU5RFJrUVR6a1lNRXc1Qlk2eWU3V1N5ZjFiUEdSSFBnMFBGMGd6QlEvd2JpMzQvUU00WGJXU3hCZmRhZGxGVkxhT05wbG5tS25ySUE3djY5MU02UlZMcCtnRXk1em9lQUxnbTlaNHBHSytScXlJYjlnbmMzOUdHcUFOZXRNRUNoVDBkYVRqczFOMUxsTk1SQTZUK1lnSWdDWURzZ2pGMDRaOThMYitsYm9rUFR4VlI1VE9EeHU1ZUJUWDRzWDArUlhGK1Y5WnZzWkxTNkFaTlZsblE3SVEvU3ZYKzdxSjBUMndhSEVDSWNXZ2RFSzQ1NTVvd21XeVk3WlZBNGRrZjI5dkhDaDFua2VjdVlPdUpyVGZib0htQ3ZrT2JvVlQ2dDY1TzRVM1FXUjYwekQ4aDV4TGVoc0tnOHdIQ3NtYzFJYWo0WVI2ZmJZcWRFYlMzbHN0aytHZlRpKzVpWVRyd3ZGRzRuZGQ5T0pCc0ttY2RmRmxZODlRMXZlMzdYTXpoeXh5N1NtR2F1TkpGNHBqakZzbmxISnh2N1ArazlnS3l4UmU4UVRIL3RrazQxVGpDdUVHRGlKUC85TnF1NUtVNlo3cURndytZTjBZUkE1UEVDMzV4clVzR1F2Y1NZMmYvTVU3OUtJTXFCY0ZXY1lvd0tjRkhFbUovTExHbGFFOFlnYnZkbG9zZFU5Ni9kYXE2NjBqM1A1dlM1alA1Rm9jVk1xRWhFNElOdjdReFFjZHRBOTdMempra2lJdzY1RXJVeWVpLytlVXozcmN5NzJXZTVVcjFmVlF4WGp0bmNyL2p5UWxJc2s0VWIvaVdObE9hWUhaRU9CMzRXS0R1ejlFRzRpYk9ncktIK3RQb1NaOHhCakhXSkJSN0xjSjI1ZXE4Ulh5MU9QYWtmeThNeVZ5NHcrczEzaldRbzI4S1UwcThPYkdVQ3doQm5td2FMdlIxS1BpUUhOUTRpSFpVWWt0QXZOQlRkZE9tamJxNGU5Tll1SEpYNlVwMU9oUm1CRUVUSjNUV0xEVFFtenlhZGlhNExlaEg1WDdYbWE3bktvZk8zTGp3WG01VGY0ZVpVQmxaS3NIbmpnVVZQeHROcUUyOGtQSXRvUGN4bW5qTHdONit3am8wc0xWdE93MG5wVXdReG15elNLcjVVSWpBM2pLeGVvbjNDYjMzZTFqQ2NFd1RvUEFTRFl0TzBaMThGR21rWHp4ZndGY0lIczBtK3FGeEhwTVdvaUl1YmlEazl1NE5FcjJqanlEVXJlWVJNa0lSRHJGSlZTUGVOR0JmOCtJT29Venc3Nm1XNXpKNmVjbzZ6ajF2V2VLL3A1VHN0M09Fcm14YVRpcDYwd0NaajRZbmtCMDh4a3ZCTVJ5ZFNadktoYWRsdlB4M2dRWEFkc1NiNDM3dHhiSm9Hdnk3VDM2ZHlhQ2wvS1QvREJYOE9TckFiNDA4VTRxZTc4YkZsY3lWZHhKQk9JQmxDYXJuRzZ2U2xKUkxyRm8xV21tK3VEbXpBc3VNdVFsWGdtelM3S2VsNDJPWkZ0bkszOXd6VnV5MXE0SUFrUEJDNklWY0lYUmR3RkxodVhoTEpZUU5sNStoZ3BlWFBLdFhpbDNYTVZFSi9FUkhMMFNyR3BnblZBd2ppelh2eXBKMk9GOWN4Rmk4Sk0wbmxIVTU1bjRxbE0yOGI0KzRNYU5EZlgzbHlWSXhlcW9Mci9HeHd6MTBkSStnR2RLQVhJVXRqZnFzMlZBNW8yUncrYWc3eGlubzBkamJIeWlrVURYcXRIczhySHlzVkJ0SnU3cU9DajVyK0IvWVJtZkc1VlVlaFFvYTBFUVRSV2hiSnpyNW1DV2VsTjUvUWJ6TGRhMzdscTZydmpxQzQzMTZ0dzJOU1dnNENMYmdZRW1ybFhxdGRCcU4yWHZRdmNpcDk1S3g0TVRRTzlhUzdJemtvMzN3NkNvQy9ocHFjUW5QNUZURmNHV0FHNTRtWGZzc0NlcFZ3cGRyYlI2UlY5Y3lkaEhMVTR1U1hjZjNBWW41T0NSQmR0MWR1ZE1penpoZHNKMkJYWm4raGdxQmZxT1AycnFPaFRVSFFqOC9UT1JLdHVwL1VJYlBqRUt2bHo1Q2VhVHBhcUxaWWp4RzlDRnBBejM1Wm5mc2pDS0NJWExidTV4MXE1aG1iQk1US1JtTXV6dFFrRVQyMWZ2L1A1akkzOXZxbmFVVmtud0p2eFhsL2RTLzFYU2RzK3IyL05kYUl6VVpqejQzSHR3MUNVbGVNNG5hbW9YYmFFc2d0VXVzR3BpeXVKMncvR0RUS2hzQWtvYlhDb1NySXBMZlBDWmk1SE1TQjhyNmg2MXcvblBmdURlVFd0aExMblBoa24wWUFZYjA1ZFZTRXNLQ1ZMU3FoSDZjUnZ6N3YreUtLYTlVeTQ3d2YxRnMzVWF5UDkvOEV0RUZaVll6S3BiU0ZpOXUvN3ZGNGd6OGZlUC9pTm52aDUrWktNNWhURXVkTmRsdVRqNmFUbjVGczFaWnVZRTlvR2xFRW5nc3BrSnF0eGZzV2xQdVJjMXZIb0ZXamZMQnZIbTZEdjNnUDB6c0g5RnVVd3NlT1JweXZjQ3BKOXgxamVTc3NZZ1BJaWJwZWg0L2FPNWh0Q1BtRTA2NkUwSThjaXlGUlBnOWlHS2dPT2pZb1hlVnJMeFdwY2QraTNXbWozSjhYNWFWWlVncjNabjROdGlpTlEwR0dKSXpvb01iMlcxLzd0UnFjWmR4VUo2UmJIZUpPWkR1NEdDN0xSa3RoNHBzbnNoMW1ack9rQXZtRFNYNlcvYUtGVmhNcTFCd081OEdoSDJkTWRNNzV1TzhZeHRjQWlzcE9acVhtMHVlbTRQYTl4eVZYVGVmQTJrRGw0MlhBd0h3Q1I2T041REhEWUhLYkk4elBHVmxyalhsdVIrSzFjNXlwZEFUeUNvUkpoa3lNRngyRXF1cXpwWDFtQ1FWY1N1ejF1SDJ0ckVPaHhtQTdSTUJHSjV0bjZJbHFvK0N5dzNoTlFoVm5hcm13UCtTTVVmbDUrU1ZHOFdXdkwwMEp2Z2hPdjFYU1U0cG1vbjREZG9QS25xL0dqT003ZmdYVllacFBvV2VOcHJYeXZhUFVyNEgrUHJqY201MHJaVUlQNUgzVHp5UVR3eHc2dlFCRHoyb1Y0TG5TT3J5MnpJU0VYRTBURllFWlczMkZudDFkaTdXdzJmZ00xZjZFT05xNFpBM2NldjZ4dDRaR3VBSE9ZR0JOd093SjJXR2prRW9uWVE0dUpEeWRDZ1VNY1B4VVlIWkEvc1FINjZiMjBSYnEzdHVGaVhvR2JyT3V4UG5zV2t4OHVma3RqQWJBTHZCQUJjM1Q5OU1BZ1NaeDIvYS91bzhiS1ZVTTE5aVVWM3o5T2J5ci9SeVdxbkRzYWcySk1OYzdRajFicnd2YmJydElMMVRRMnNXU2dIUk5XbThzMnY0WDFSTWVxWFdXZm8yaC92SmQrQWxTblFISVJqcENpMG9kUThZNWxDZW1GRG1KMUh5dWlHZ3JLam1jVE0zcUVlOFJxMlhRSnNBK3hxTCtxaXo4akxXc1B2VE5oaXJ5UlYxZTYwcTZiN3pYUWpGc3hpS25SUlJSK2N4YmozMTF1R0QrdjlpTDlsaU5FNzFLWG5sbTJxUlhyODJEekxIbjRMa1RnN202ZVpvWkVZd3hPbFdsS09SYWRpTE9LL0g3cks1VGk1UmV6bmE5V1RaYXZaZmhjWjR0eERtbjhyT3RFMWkwK2Z2NFZFdmpsaVN2K3BLeFdwNHpoQmM0UjdZamZTeThENkNTRGU4bzJoQ2VnOFduTUw1NFRnM1ZHUkN4Q3dod2NlSnZqUWt3UlZPYjdZaVN6eWxYaThRcDlXa24rRnIxYTd6dnBNOUc2OGhONVBBalJXRGNWVmhCSlhFVlJZc2dFZm95b0pBUlkrRmE1VitoNUR2S1h1RTlGcndCb2xmYkthZGRWbU9VNGVkeDJ5WlhHN0N1enE1bjljRHBQK210V2RiMURvekN2T3gzcHdmaFh1VTlGenlyaGQ1UGRBVTFNMnhkaXBXK1BIZHRlM1VHMzlQQnBVU2lSRlh1ZXNrK2xnbjVDUCtIYy91MjBuTHhGZUNhbWxtVTRVMDJJaUpocHg1Y2g2MWpGVXZmL0xNUVFrajhvV0lIRGcrMHFKdjY2MkxwTHo4NlZhbEg2SmJIWjlCN3FmOHh5MVc1eTc4N0pjUVJmNzJ3RWlYZVZWbEdmV1huM292UGZRdS9jcFhCM2RjRDZoY1NHYTJhaVI0YTZMalEwYm1Id1hxdlA4aDZZcXNvK3kwM1E5ZkJ2b1ZjQTlEclgwWHhzVHVNNlJ0RWIxS2h6SXFJcDQyaWErbmdSZzNFMjNyUytUQ1hPbzlnODRmbWZ0WGZvSG4yaTZaZDBTM0FVVG1WNmZtVFNuWTJrb09ta3l5QjZ1UlVSM0FTQ0piZ3RBbkpQNUYyaGY0SUoveXA5RDBDU2ZsZWRlVGdCY1ovUzBJbUlpSTdKZmViQUtaaktxaVJCZWVSWHdQVXZaa0F2VThWdFRqZVFNS1ExM0lnTVZ5THU0bXh5RUZXN2kvT1ZzTU1mdDQvTWVQOTJudkYzeUI2YWJ5VGpQdzBRL0VQMlJRYlFkdFZZNVpEaGNHeUZ5RURYWE9JQXpWb2d2ODBWNExCa3o2SmRhT0IvV0Z6U1hUOVo0RHI5MWR4blQyUlgzbjA1N2hSSisrbk9obzlkaU5vdkNUSFppejBEbTBsMkszNXF2ODkvOHVzcjFINDBtQWh2blFFRXBHdFkzRVdhL3FWRGRJOTEwNElVYTVnUlFvZk5rK1ZLUXhLNjNXZmp4MlV1QXU0L2tYaU5ycDYrRDF5SEtOL2c1VS9RRG02VUtOdTQxSytLOVlpbW9vek5LMlk5TWhFbnVzYjM4RnZucm5LbjlBRVQ3NTlHZDFpMVpDZ2lDaUtyMnBhUlpXazJVMU5mOW1BT2QxU2JIUEVrUVY5cnc0SDJyb0ZYU0xYLy8raTlGTzVDNktiNmtDa3dkcnJFb1NrbkVEdEdaSTZURURkT3hDSk54cWxzM0VCNUlpTGFhRjV1dGNsa2hadGxmZGtXQU5Id0M4QmcxNWovYUVGblo4cE0zUDdYMzhYa2x5QU9HTWMreHNJU3ZieXFRL2UvL3dQclJ0bllLOXcxL1FBQUFBQkpSVTVFcmtKZ2dnPT0iIHRyYW5zZm9ybT0ibWF0cml4KDEsMCwwLC0xLDAsMSkiIGhlaWdodD0iMSIgd2lkdGg9IjEiIHByZXNlcnZlQXNwZWN0UmF0aW89Im5vbmUiLz4KICAgIDwvZz4KICAgPC9nPgogIDwvZz4KICA8cGF0aCBpZD0icGF0aDQ2NTkiIGQ9Im00NTUuODcgMTg4Ljg0aC05LjE4MDJjLTIxLjczNyAwLTM5LjQyMyAxNy42ODUtMzkuNDIzIDM5LjQyMnY1MjMuMDdjLTI3LjM0IDE1LjY0NC00NC41MDkgNDQuNzc1LTQ0LjUwOSA3Ni44MjYgMCA0OC44MTEgMzkuNzExIDg4LjUyMiA4OC41MjIgODguNTIyIDQ4LjgxIDAgODguNTIyLTM5LjcxMiA4OC41MjItODguNTIyIDAtMzIuMDUyLTE3LjE2OS02MS4xODItNDQuNTA5LTc2LjgyNnYtNTIzLjA3YzAtMjEuNzM4LTE3LjY4NS0zOS40MjItMzkuNDIzLTM5LjQyMm0wIDE1LjVjMTMuMTU4IDAgMjMuOTIzIDEwLjc2NSAyMy45MjMgMjMuOTIydjUzMi42NWMyNi4xNjEgMTEuMTA2IDQ0LjUwOSAzNy4wMzEgNDQuNTA5IDY3LjI0MiAwIDQwLjMzLTMyLjY5MiA3My4wMjItNzMuMDIyIDczLjAyMi00MC4zMjkgMC03My4wMjItMzIuNjkyLTczLjAyMi03My4wMjIgMC0zMC4yMTEgMTguMzQ5LTU2LjEzNiA0NC41MDktNjcuMjQydi01MzIuNjVjMC0xMy4xNTggMTAuNzY2LTIzLjkyMiAyMy45MjMtMjMuOTIyaDkuMTgwMiIgZmlsbD0iIzEwMGYwZCIvPgogIDxnIGlkPSJnNDY2MSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ2NjMiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ2NjUtNikiPgogICAgPHBhdGggaWQ9InBhdGg0Njc5IiBkPSJtNzQ0LjkgNTk0MS45aC03My40NDJjLTE1NC4zNyAwLTI3OS45Ni0xMjUuNTktMjc5Ljk2LTI3OS45NnYtNDIwNS41Yy0yMTguMTEtMTE2LjItMzU2LjA3LTM0My4yLTM1Ni4wNy01OTMuNjUtMC4wMDQtMzcwLjk2IDMwMS43OS02NzIuNzUgNjcyLjc1LTY3Mi43NSAzNzAuOTUgMCA2NzIuNzQgMzAxLjc5IDY3Mi43NCA2NzIuNzUgMCAyNTAuNDgtMTM3Ljk2IDQ3Ny40OC0zNTYuMDcgNTkzLjd2NDIwNS41YzAgMTU0LjM3LTEyNS41OSAyNzkuOTYtMjc5Ljk2IDI3OS45Nm0wLTg4LjU4YzEwNS4yNiAwIDE5MS4zOS04Ni4xMiAxOTEuMzktMTkxLjM4di00MjYxLjJjMjA5LjI5LTg4Ljg1IDM1Ni4wNy0yOTYuMjUgMzU2LjA3LTUzNy45NCAwLTMyMi42My0yNjEuNTQtNTg0LjE4LTU4NC4xNy01ODQuMThzLTU4NC4xOCAyNjEuNTUtNTg0LjE4IDU4NC4xOGMwIDI0MS42OSAxNDYuNzkgNDQ5LjA5IDM1Ni4wNyA1MzcuOTR2NDI2MS4yYzAgMTA1LjI2IDg2LjEyNSAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NDIiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ0NjY5KSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNDY4MSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMzYyLjc2IDkzNikiPgogICA8ZyBpZD0iZzQ2ODMiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDQ2ODUtNikiPgogICAgPHBhdGggaWQ9InBhdGg0Njk3IiBkPSJtNTMwLjEzIDIxMTUuNHYzNTc0LjJjMCAzMC40NiAyNC42OTUgNTUuMTYgNTUuMTYgNTUuMTZzNTUuMTYtMjQuNyA1NS4xNi01NS4xNnYtMzUxNGMtNDMuMzI4LTcuMTMtODEuODgzLTI4Ljk2LTExMC4zMi02MC4xNCIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudDQ2ODkpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc0Njk5IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAzNjIuNzYgOTM2KSI+CiAgIDxnIGlkPSJnNDcwMSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNDcwMy00KSI+CiAgICA8cGF0aCBpZD0icGF0aDQ3MTMiIGQ9Im01ODUuMjkgMTUzMS43Yy0zMC40NjUgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnY1MjguNTJjMjguNDM3IDMxLjE4IDY2Ljk5MiA1My4wMSAxMTAuMzIgNjAuMTR2LTU4OC42NmMwLTMwLjQ3LTI0LjY5NS01NS4xNi01NS4xNi01NS4xNiIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudDQ3MDcpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc2MDkzIiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAzNjIuNzYgOTM2KSI+CiAgIDxnIGlkPSJnNjA5NSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNjA5Ny0zKSI+CiAgICA8ZyBpZD0iZzYxMDEiIHRyYW5zZm9ybT0ibWF0cml4KDY1OC4yLDAsMCwxODQuMiwzNzguOSw5OC45KSI+CiAgICAgPGltYWdlIGlkPSJpbWFnZTYxMDMiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBUklBQUFCTUNBWUFBQUNoeERnUUFBQUFCSE5DU1ZRSUNBZ0lmQWhraUFBQUdRSkpSRUZVZUp6dG5mdXZka2RWeDc4ejgveXNnc1ZTU2x0N295VTBoYWJTVk5BYWJWS1RrclFKalZoTTBYSXBwTmltQ0RYSXhhWVJCSWtWSlkwMGxsZ0VtdDVMYWNHYVlOUUVqVVlpVVFsR0V2MFB2S0QrL3N6NHc4eWF2V2J0TmJOblArZnluUE4ydnNtYjl6eDdybnVmTTUvelhXdlAzc2Q0N3dPR3V1WER5Ymxjd2Z2dXVsZmMra3gzWFdOZHg5amJycjYrKy9ndDNlTzJ4K3M3MTZYdlRsajQvcTI1cHIzOTFzcTBzV3AxdFo4N3JhNSt6SGZVQ2UzUFlxNUJYR2t6UUxKT3B4VWtYZjBkcE8wUlg1Y0JrcE1ORWp2cmNXaG9hR2lsQmtpR2hvWU9yTTNMZitTSEFBRC8vVC8vdCtlcERBME5uVGE5OXJMTEFBQWJPakNBTWpRMDFDc0NDR2tqS3d5Z0RBME4xWFQ1WmE5Umo4OUFRaHBBR1JvYUl0VUFRcXFDaERTQU1qVDAwdFZsbDE3U1ZXOFJKS1FCbEtHaGw0NTZBVUxxQmdscEFHVm82TXpWYXk2NWVLZDJxMEZDR2tBWkdqcHp0Q3RBU0R1RGhEU0FNalIwZW5YcHhSY2RTajhIQmdscEFHVm82UFRvc0FCQ09qU1FrQVpRaG9aT3JpNjU2TUlqNmZmUVFVSWlvQUFES2tORCs5WlJBWVIwWkNEaEdpNWxhT2o0ZGZHRkZ4emJXTWNDRXRJQXl0RFEwZXM0QVVJNlZwQ1FSdGd6TkhTNHV1akh6OS9yK0hzQkNkZHdLVU5EdTJ2ZkFDR1pQM3Z4RytIdHQ5NjY3M2tVT3NsUUdhOWFyTFFkcjFwY1hiYnJxeFl2dk9DOGxiTTdXajMreEJNdzMvajZDeUdFZ0JBQ2Z2a2R0KzE3VG9WT0lsQUdTQ3B0QjBoV2w2MEZ5VWtEeUplLzhpaU1NZkhmMTE5NFBvT0UvcjN6OWwvWjl4eG5PaWxRR1NDcHRCMGdXVjNXQTVJTHpuLzFUdk01U24zeFQ3NDBBY1FZQUlCNS9tdlBaWkFBY2FIUTV6dmUvYTU5enJlcWZVSmxnS1RTZG9Ca2RWa05KQ2NSSGdEd2hUOStKTVBERWtBSUtGOTc3cXNoaEZBQUJJZ25GTHlIRHdGM3Z1KzkrNXgvVThjTmxRR1NTdHNCa3RWbGZLenp6enQzcDNHUFF3LzkwY093eHNCWU96a1FCaFJqRE14elgzMDJlQkhhMEFuRzR4N0JSOURjL2F2djMrZjVMT280b0RKQVVtazdRTEs2N0x4eno5bHByT1BTZzMvNCtRUVFBMlBpSDV6Z1FDbGc4dXl6endRSkVRSUlnQXlSRUFLODkvREI0NFAzM0xQUDgrdlNVVUZsZ0tUU2RvQ2txK3lrd3dNQVB2dTV6OEVhQzV1QWtjT1lCQlFOSnVhWlo1NE9Ha1NDTDNNbUJKRWMvdmlBZXovMHdYMmViN2NPRXlvREpKVzJBeVRWc2xlLzZwVTc5WG5jZXVEM1BndFk3alFpVEFCTXpxTUdrNmVmZmlwb0VLRUZVNFBJTmdEZWV5QUVmT1REdjc3UDgxK3RnNEJsZ0tUU2RvQWtmMzFhd0VINjlHZCtGekFHMWxvNGc5MWc4dFJUVDRhMVRvUkRCSWgvQjlSdlk1M2YvTmhIOW5VOWR0WWFzQXlRVk5xK3hFRnk3amxuNzlSdVgvckViMzg2Z3NFbFNNQWNEQ1pQUHZGNG1OeUhWM01pS2tSaWd3SWlJY1EvTFJ5OHgvMzNmWHdQbCtmZ1dvTEtBRW1sN1VzTUpLY05IS1Q3Zit1VGNlR0QzWGxaQVpOcXpvUkEwZ01SQUlVYnFVR0UrZ0NBVDl4LzN4NHUxK0dLdzJXQXBOTDJEQWZKYVFVSDZXUDMzVitBUUlNSkJ3bUFSWmdVSUhuODhjZUNoQWlBeFpCR1FnVEFEQ1RlVCtYZWUzem1VNS9jejFVOFpQM25ELzUzMzFNQU1FQ2kxbHNxN3dESmFZY0c2Y01mL1hoZStDWUJvblFVYlpoSWtBQ293a1FGeVVGQ0dnMGlRQUtUaiswZStKMVBIZjlWUFdMdEF5NERKRXE5cFhJeHoxZWQvWW9kWjNSeWRlOXZmRFFDQVlDMXBncVR3d2h4TWtnZWUrelJzUFl1elM1dWhDQkM3WHlxKytEdlA3Q0hTMzA4T21xNERKQW85UnBsNS96WVdZY3ptUk9vdTMvdFhsZ09pdVF1V2lBQjFyc1NTeHZUQkV5Szk1RklpUFJLZzBpdjd2ckFoekowSG5yd0Q3cmJuUWFkOWJJZlZvK2ZsTkRvVE5XWkRBeXVPKy8rUUliRGtuaGtZSzJGQnpKTUZ1VURRaHJDdzJlWWNEVmZiTVRkeUZxRkhhQnk1MTMzWU9zOUh2NzhnK3NIUEVXcUFRWVlrT25WSzE4aXNORDAzdmZmRGRjRER3VEU1ZWU3WUZNMlRtdVhYTW1DdXQrUXRnWUk4N1l4ck9uVkhYZmVGVjJLRDNqa0N3L3RQTzVwVkFzeXBETWRObWVmOWZKOVQrSEU2VjEzM0Fsais5MkgzL3FjODFpcmdKQnpMTDNhKzZzV2wzVDd1OStYYmRtWEhubDR6N001R2VxQmpkUi8vTmNQam1BbXl4cFEyRjIzM2Y0ZU9QYkU3WEZvVndDZGVKQnczWGI3ZTNLNDlQaFh2cmp2Nlp3cXZlSkhYN2J2S1F4MTZPM3ZlT2Y2TUVUb0lPRFoxY1YwZzhSYXUzTjRZNjJCOTFnVjNzaHhaZkwzMXR0dWg5OXVFVUxBMDA4OHV0Tzhob1pPZ243aDF0dlNIUk9YanhsanV2SWdOZTBLQkdDNi9idEdWWkFRMVlJRjNNcUVLNGZPV2dBNWE3SE5tZVhwMlorVzN2cTJYNHBmQkkrdEQzaisyU2Y3SnpzMGRNeTYrWlpmQkFBNGF3RGxEZ2hYdm1YTGJ1UDJTdDcrWFN0KyszZEpHU1RXbUhoTHlGckFBejcwTFg1alRIeDR6MXBZNytIUnQ2Y2dqdVB6bU43NzZGY1cydEtGOWQ3RE9nZS8zV2I0a0c1NjY5dXlpL25UNTUvdE9vK2hvYVBVVzI2K3BiaFZTeEJ4ZWQrSDY3NlY2NndGeEw2UUhta2IwbnFsUGNESHRUSEdBbmI1TjM4a1UxekFKa1RyUkp2TGVpWVBlSGpmMTA2NkVvczRyZ1FHSHlPV0FjNTZiUDBFcDdmY2ZFdmU0K0s5eHpkZmZLRTUzNkdodzlBTk45NEVhOHRIN1drUkx6a1IrbmxlNDBaYW05RmFxbTFHVzVMYzJUb0xiZkxDOTNIekNSRERHMVJBazA5T3VKSmFiZ09ZWU5MclNvdzFNNWhJVjhJcXoyQkM4NEsxdU9IR202WlhTWHFQdi9qbWk0c1hiV2lvUjlmZmNHTmErTFlOa1NUTmpVaUlTR2x1aEVORVUydDdmRTFMcnhPUTJzU1FKcm9TNi9Xblc0MHhPVmVpdVpMWkpGaVl4SE1sNUVwNDByVUdFd2tNc1BJYVRMSmpXWUFKemRnaWZ2UHB4VTViNy9HdHYvcnp4dVVkR3ByMHM5Zi9mQUVLQUgwUXFZUTBIQ0lrN2tacUVPRmEyaG92dGJRMVhwUDI5Ty9HWkJmQllBS1dLMEhwS21vaERvRHNTdWczUHM5bkFDVk1aSWdqNFRPRFNWcitTODVrNHh4Q0NHcVlRekN4enNWUUJ4RW1lUWRmK3VIWWVvK1EzTlRmZk9zdk8zK3NoczVrL2RSMVB4Y1hYYzV4MkJJVWR2cHQzUXBuNkU1TWp4TlpnZ2lwNS9tYVhMZnlzSjU4aFFCcDZXRTkwcVpzVkljSlVJWTR0RGhyTUxFcEw5RURreno1QldjQ1FNMlpaS2V5MmFSZGZWczFad0lBM2hpRUVBcDNRcGVEeWh5QWJaclBULy9NOWJIUDRDUDRFUEQzZi92WGZUOTlRNmRXMTc3cE9saG44M01sQkJFT0VBRFZVQ2FYQ1lqdzI3dzFpQmdyMjllZFNDOUVXay84eXJ4SXp4Ty9KT3B6WTR3cEZqNlFTTmlBQ2QwTzdvRUpnR2FZUS9lN1d6a1Q1eHdRUWpVQkN3RGJCQUVaNmdBV01EN2ZHdWJ1eEtVTDRjblZJTG9UNHh4TWNsWmI3d0h2NFl5RE5SWStlRno3cHV0eWVPYVRlL21IYi8vZFFYNXVoL2FvTjE3N1pnQnBVY0xzREJDcXJ5VlZhNkZNVVdidDNJWEVUcHM1a1o1d3BnY2lyYnlJREtQa08xczMyU0x4RUFkWWRDWU9pTzZrQVJOakxRSXdTOERTcENNMzRpc0d0QVNzUzJHSzZrNHNZTHlKNzFFUjdvUytvVE4zNGhDQndxQWpnV0lTR0lEb1VEaFFET0tZQkJTLzlYRFdJVmlMTjE3NzV0d3V2cGJCNHgrLzgrMmRmckNIamtaWFhYME5ySmwrNHhNSThyczdFa0JvVGZRQWhPcjE1a0p5SHl0ZENJQVpSRGhBZU52YWJkN0Yxd1VBWFU1RWU0djhCSkk0RStZaTZtRU9MZEphQWhiQWxQOVE3dVlBeUhkME5IZGl0Z2FXWG1rQUJpQmo0SXhSM1FsZDZNRExXTGhqMGk3WUdsQ01jekFoRkE3RkpZaDQ5czNJVVBFbVFpU0ZQUVlHc0M0OWNSbkxmK0thbjh4UTRlY01BTi85cCsrc1h3bERYYnJ5RFZjWEM3QUhIa0RwUHFndXo0RUFXSFlnc1dFM1FHTDFlUzVFOXQzclF2ZzRtZ3ZoYldWaTFmQnhHemtSN2UvYWJDakpTUjBFTStVUU5KaFFTQ0ZESFRCQUlBUTExSW5yTjRaUXZlNGtoeHRzMFJmaGpqRUlOb1UwRWlqcGYzNk1nRktFUEt5ZU55WURCVURUcFFEb2dvcUZRL0RUZXh4ODhManE2bXNLc05DMUNON2pYNzczejgyRjhsTFg2NjU0UFlBSkNnQ0tSWlRCWUUwQmdMWHc0R05vN29QYXQwS1lQSFlQUUdLSHEzSWgrUnlVTnJ1R01zVjREQ0lTb0ZPYjVFaHNPb0hpMWk4UGRheUhnOG12WTVTaERweUJveGRFTjl4SkVPNmtDRjJZTzRtYmUwMStveHFGTzNTUmw0RGlyRVB3b1hBb05CWWRBNUNnTWdFRllMa1crbVlRVk5LZG5nd1Y1K0o1Q3FkaTRSQXNBMFFLZ1RTd09PZXlZM0dwUHppSEs5OXdkV3pMNEZKOFRuMS8vMSsvaHpOTmw3LzJpbUlCQWloZ0FHQldMaDBIbFhGdzVIcEsyRUo5U25qRU5vMEVhcXhjM0xKZHlvSEVKZ2NEQ0QvZWt3dWhhMUM3dmR2S2g3VCt1bDRlenhoc3JMVjVrZE15SnBIZER6NUJocmtUNXh4c3NQbU5hc0RrVHB5enhhc1p5V0ZNSVZFODBhQUFaVnJvSlZETTFreHpGQTZsQ0htb1BjdWgwRW0zWEFvUTU1Q2g0aXkyUGl4Q0JYUWU2YmNRN1VrQlVBVUxnSHdIQ0pnRFkvcmJRanBBNlBNVlYxNVZ2SUtRUDlQRWI5bHJ6enJSc1gvL3QrL1B5bmJWSlpkZW5oY0xsM1pMVVpieDdkb3RZTVM2YzJnQXFEb09xaXRkQjdXVllZdGFSNEVIOVY5ekg3bmNUa2xVT3RZS1lYZy9Td0RKN1FWQXBybk5iKzN1OG5kcnFJeTdFQTZVTWtjQ3dORUNaZTlqemJrVDQrQk5nS0dYUmRNM01hNzUrSXBHRTl2SmNNZTV5V0dzQVFxRlBOeWhSSWhKOEtSeEVsQUFaSmRpZkFLSkx4MEpRWVdEWmdhVjJEQkRKVHVSRUpLajhQQXBiT05Bb0x0SCtaMjIzTEdRYTFMZ0FtQVJNRVdiQ2p4a0dlK0g2M1ZYdlA1QUw2MGlhUUNSejNMMEFJVy94cThIR0x3Tmh3YUFMbkRFOXZPY0IzMjk1RHh5bnhWNGFPWGNmZVQ2MGxuczZFQnlmNTBBNFgzMy9zRndHcVBJa2ZEQitjdWNjNUJCeDJsaHAzREhPR1NnNU1VcGdNTERIYUFQS0xQd0pTMVltOElXK2lFaWwwSkE0ZWRBbjdsTEFWQ0hpbk54RTFvYUs0OExwRmNWV0RnWEhZWjBLK0QxRTFqSXNSUWdjQzRuc2psY0FPVG5oNElWemtNQWhzNmJybVUrVm53dFlLSThmTmtMRGdrbStVdW5KdFdWaUdkTCtGNEV6WTBBeThEZ2JXV1l3dnVxT1E2cVd3Tkg3bGZBWStZc1pEL2kyUmgrQjRibVVYTWZkTjd5cWQzREJBaGR5OTQ3TXJsdkFROWV0bkdibEdOZ1A2elo4aWV3eUdTc1NmWjlMVkI0eUVOQUFWZ094Y2JieFpTVWxXNGpYa3oyT2JrVUJ4ZHpLV0c2RmN4ZlJMMUo0UnM0TkJTb0FGUDRRMS9YM0lvR0ZxcEhlMUlrTUR3N0Y1NW5vUTFLUzREaGRXSi9MS3hSWU1OVmc4ZGh2WW0rOWlTcDZsVFlVeDc4dlJtRkc2bkFRbzYxRmhwRjNVNXdVQnNPaHRMaDZIVTBlTVR6N0hjZi9IUHJWbTYrcmpzNGtIZ3QrZ0VDTnJaejhUeWpJekVzeDhDMnJjK0F3bzkzQUNYZjRlRWhEd0JuYlBGM2NyaEx5WXVKdXhRMkYrNCt5S1hRUlNTWEFrQ0ZDaURDSHdZVmgrUXMvT1JLQU9Sa0xRQUVjWHh5TEF3c3dKUzRGYTZGNEpLZEM4cG5tMlFvWTcwT0VMNzQ4NWliT1JTMEJ5WjdYdytoUFVNRmxBdS9KZTFONDlMUlNQandGL25JeFEvVWs2OXh2TjJnVWRSdGdJUHFTZGN4OVYyR0xid1A2VHppV1Azd29QT3F1bzgwYml1Skt2dGNDeERlSHdjSUhjL0pWcHBNTDFCNHlFTkFtZVZRS0N5UlNkbmtVZ2dvUU51bEFNaFFrVzZENW04dEEwZDYyVFIzS2pRR2R4aFZ0eEk2d1JMS01rZmpMTUNGMnBKa01yVklvSW9FcXl6ZnlESUZIalhYb2IyU29laDRwV3B2OU5MY2lvU0tWU0RDanhlN0tsdVF5WXQvSFRUS3RuWmUxZ0FISFpNNUQycXZoUzI4ck9aYStCTzZhOXpITkFlcjlEa2xVZWxhU3JmUjQwRDRzUmphR0F1UGtCMERYUndKbEF3T0JoUVlreGQxb001RHVzdVRnQUpNb1lNTlU2NkVYTW9HYVVFZU1sU29INWxUY1pnZ29ya1ZEU3hnYllDMGJ3WllEUmYrdVZqRUFqTFVEeCtESHdQbWNKQlBiV3Voakd4VFk4VlNEa1VMVnpScEFKRnQ1ZE9yV3RnaTI5VmdFWS9OZ1ZGOFZxQkJuemswWk4yMTRJaGpzMXZReXI2UFdmMEtQSXErV0x1RHVBL2VwbkErRllnUVFHYnVCTFNQeEpwcGp3Z0RDb0dEQTRYREpMc1Y1bEp5dSt4UzRxRGNwZVFMSDZZTmFVVXVCZWlDQ21oZkNPWlFvWGJ4QWdod2tDT2dPMENZZ3dWUUhFczhDQUF6MXdMb2NLRzZRQXlMZVBtRzFaR1E0ZTFpWXg3S3pHRkRVdUdoT0JUZzhISWpVclZjaVhRZ1FQc3VEbjl2aHdZS1hsKys2NVQzclFHaktGZWdrZXV6UlVOMStLS2ZIV3U0RHVwSGcwK1A4NkMrZXVGQjUxWnpIM3dPV3VpU3J3TURTRkVQSmFSeWFHTWNMZjRFRkpRdXhORTNXWVE5TTVjQ05GMUsvSnhDaHJSdzEwSUZtRUFBWUxxakJPUy8rcWVCaGNLTmZNejZLbGlvRFQ4Zk9sYURDNVMyRWpDOGpvUU1yOGRCQTB5d2tjZDVQOU1BYzBoc3ZRNFVxUnA0cERRd2FDcUFrQnZQWWFNQkF0QWhBZFFjU2lzSjJ3YU1CZzM2YkpWamNXNmw0NkE2TlhBQTRsWXpPMTdMZVFEb2dzZlV0Mzc3dG13N0J3Zi83TngwVFdaMUdFQjR1eUpIRWhkL3lFQUJNQXQ3dUVzQmtLSEN5MnRRb1dUakVsU0FldmdEWUxyenM3RkFDRTIzQXRUQndqZmpBUkVzc2F6TXNkVGdBbmJjQ0pEVUFNUDdjSDUrak5jdEFKR2VHZExxYTNDSjEyTmIxT0hxQmNaYXRRRERvVkNyMzNZcGRWQUFjMWp3ZWozQUFOQ0VCakIzRzd4T3kzSEV0bk53VUo4ejF4RW5JOXhEMjNuUS9EVjQ1TEtLQXdHd0dMNU1ZODNyYkp4MStXLzlGcUZMWHVpVFN3R1FvVUkvaUR6MEFTYUhJa01mb0E0VjJGaTJsRk9CVFVCSmVRaDZOMG92V0RUSFFuT21jVFRYRXNzbTUwSjl5N1owanZ5NFVRQUJ0b2hib05sQTJSZXlrRmlWOEduVjFkUzd4NlEzVHdMb2NORVNzelhIQVV5UWtHV2FHNUd3NEczV0FDT1cxNkZCeDJXZk1sU2gvMlhmUzY0ajlqbmZkVXJYUXd0YjV1MTNoMGR1Yit2dENYQWI2MnpjU3A0WCs5eGhrRXNCVU9SU2Nua0lSWkpTUW9XKzFxQUNDcTFDQU5LdVQyL1NvaGR1aFMvYVhjRUNJSWRjTmt3N0xscHc0Wi9YQW9iM0lmc0dLcUNKSC9LWE1uUnA1VVlJSGEyWGVSL0dUdGFXV3BBeFNxalRjaUdBRGdsWlQ0T0xWY3FYZ0tIVnJZVW84WHdtdDBIbDZoZ0NYTDNnb0xHV0VxWjVuZ3BNYXZEUXltVHVveGIrbEE4OTBqNFNPMTNRdzRZS1VBOS82R3RmOU4vblZoeGNGU3pSNnBzQ0xQR2tmWjVUQ3k2eEhHazhIUUs5Z05IRzR2VjVYM3djWHNkaHZ2aFZoMUZ4SGMzYnZFdDlOdFNmSjZtQVJXbmZDblVLY0N4QUJKakRJdFp0T3d5dGJDbEVVY2VTYmlOMmxOdXNCUWVOMzNJZDhuaVA4NkE1YS9EZ1gvUDVHbGFYNnN4eUpCcFU4djZQRHFqQXhJV3psRlBoL1ZuUmQxNjBkRHVYeXIzUFlBa01MSnBqQWFaRlRVNUl1cFo0RVdpZmgrSmNsTENJdEFRWWZpd3YwSTJBaGdJYlBuN1JOcmRwMy9iVjJ2SE14Rkc3RVZMVGxTZ0FtYjNFZU1HbDhJMXhMVkR3dGoyd0FIU1hVZFJkQ1EzNm4rYzRwbU5UcUJLUDFjRXhhOU1BaUV5WThqb3liQ25LVkZEcDhPQjFOL1RiSXVjdkZLaFlXQVRQRjdydVZLWUZINEhpUTc5YjRYVjZ3UUpnZXI4SndPQVN3U0pkQzdBZUxrREZVVlFjRE0ySnp6dGZ6eVRwWnVUWExlRHcrWERWZHFJZWRtNUVxbnRQU2NXOWFEdGxPUmppR0xyN2tQMjJRQUdzZ3dXZkd3Y0c3M3N0TlBnY1pJS1U1clFyT0FBOVpNbGZLd25UVnA4MWVQQjYvSncyZWNNTy9jWkhHeXFBSHY1UVBhcGJoQzlMYmlWK0FOQUdDeDByWElDMU1kY1JwaHdMdVpaWVp4MWNZcHMwSndFWTU5aUM2NEFNdjl1amdZYXVkVDYrNEQ1a3VZUlByVjNaNTdydzVTQ3lTajVrS21zblc3WFBMWWhva0FCUUlHa05MUGg0TFdERXczVm9jS2ZCNTFuTGNlUjJDbERXZ21NK2h6cEFaTTZEWDdOcU9NWEF0N0dXSGhqell0L0hmQThGTFd6ajJEczk2TUczaFJDb2RCRUpCZ3BZbGh3THdGeEF4YlZRblI2NEZHMFhBQlByOUVNR3FJUXNMSnprc0luMVN5M3RZT1h6bHRyMUtkKzE2czJWMU54TFQ2Z2pON3BwZ0pCZjE4SWY2U3lBT2l6NDF6MHVJeDVmQnczK1dhc2pReFd0SGdjSHphWFhkZkM1OHpxcUs1cmxhZUs4V0k3RVpoaUg0TVhkbENtRWlPVnM4ZE5PV0JZQ1VaMWR3Y0xieU0xZlczRlhoTU5sbHZoVTRGSzBuZVU1ZE1BVWZRcklsRzZKL2JDeTMvd3FiTkw1NUM4RlFqaDArUGlVNzZndC9oWVNqbW8zYTAyMVhhNjVYRHVtUUtVM1B3SlpUem9LSUlPQzkxczZteElXdkh3Tk1MVC9lNkVCb0pyanlPMHFqa1BydHhjYzgvcTF1ME4yMW5aamJKeFVkQUVwSkRIMFdzSlltZHhLa2ZSRTI2MnNBUXVzQUFIUHNhQmMvSVl2TE9aYWVCM051ZkQvZXdGVDFCV1FLY3ZtK1E4NDVQTlZIYzNVU2Y1eXl5Z3dnMDVzRENrdFh3SlVjaWFWaFgxUVJ3S3NjQ1dWcDRkbFhpUjFXcllWODY4QlFzNm5GeFN4WElkRldiWWVHSzMvbDZBQllKYmpvTEpXQ05RS1Y3VDJTNjVqTmk4ekhkdlFlMFBqd3ZVWktpWk1JVXZOclJ3bVdJci9yWkliOFJNRW1uQ0pYd0E0T0dEVXV0WDhSaDAweGR3Z0FNR0FZeUVYdFoyMVRSMURrd1JSMGVTWTNRaXBsWXd0UUZBMFduWWxHaHkwTWV0NUZMNndyRkpldnoxOEdNQm8xbDl3Ry9UL0hHQjF4MUZ2MCs4NjB1Um1vQUpTamlRWStzMXBNbFNBRWl6SWE1UmVUMGlmOVRBb3RwK0hHeklVaXZYTE8wSkZmZW9qSlhCcmNBRndhSURSeWpJVTJPc1R0ZnI4L0l0Mm1HQVQ4ellpbEJHd2FDWmVIYlVwNGNDWDA5eGxISjBiQWZvZFNhdWU5ZzZUeGR2QUVqQUtJR1M5MmhQR2VxaFRCMGt2TUdwMXRiekdVdithMjVpZjk3TGptSmZYd1RHMVozVUtWMlN4TWF5VFlKQVc3UFIwNjBIQW9qa1dnTUVoZmMyVHQ5SzEwTGo4TTNjdWdPWWFEZ1lZYlN5bkhKTmZhN0NaSlZNcndDbmE1N3E4M0JYbkpsWGJUNktOZTV4YUFvd0VRVzZudWhMaFBHWVFhUU5DZm0wWDZpd2Q2M1VZQUxxZ1VRK1YrcUFoKzJuZlZqNFlPRmhWQU1CbXMzSEZ3dlkreElvbXNXSUhzTVQvMllLaHhHU1JCMm5BeFpRTGxqc1hPbDZGaTNBdlJka0NZSXFYTmZPRjF3Qk4wYjRDRFBtWkE0ZVgxUktwR2dSYWlkTWxxRXo5SG02NEl4ZDZUVFY0QUZBVHRLMndSdnRjQTRUODNBMktXRkN0cDRVazhWejZYY3hTWG1PcGJTdC9JNzh2aHdFT0svSTAvdzk1Y0d6L2FGZkY2Z0FBQUFCSlJVNUVya0pnZ2c9PSIgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsLTEsMCwxKSIgaGVpZ2h0PSIxIiB3aWR0aD0iMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSIvPgogICAgPC9nPgogICA8L2c+CiAgPC9nPgogPC9nPgo8L3N2Zz4K\",\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnNjkzNCIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjEwLjg3bW0iIHdpZHRoPSI0OS45NjZtbSIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDE3Ny4wNDM3NSA3NDcuMTYyNDkiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiA8ZGVmcyBpZD0iZGVmczY5MzYiPgogIDxsaW5lYXJHcmFkaWVudCBpZD0ibGluZWFyR3JhZGllbnQ1MTM5IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxZS03IC0yNjEzLjcgLTI2MTMuNyAtMWUtNyAyNjUyLjQgMzk0Mi44KSI+CiAgIDxzdG9wIGlkPSJzdG9wNTE0MSIgc3RvcC1jb2xvcj0iI2M3ZTQ0YiIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTE0MyIgc3RvcC1jb2xvcj0iIzYwYmE2NSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NTEyMSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoNDU2LjIyIDAgMCAtNDU2LjIyIDI0MjQuMyAzNDk0LjMpIj4KICAgPHN0b3AgaWQ9InN0b3A1MTIzIiBzdG9wLWNvbG9yPSIjY2FjOWM4IiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A1MTI1IiBzdG9wLWNvbG9yPSIjZjZmNmY2IiBvZmZzZXQ9Ii43NTI2OSIvPgogICA8c3RvcCBpZD0ic3RvcDUxMjciIHN0b3AtY29sb3I9IiNkNGQzZDIiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDUxMDEiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEzNDUuNSAwIDAgLTEzNDUuNSAxOTc5LjYgMzA2NS45KSI+CiAgIDxzdG9wIGlkPSJzdG9wNTEwMyIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTEwNSIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIuMSIvPgogICA8c3RvcCBpZD0ic3RvcDUxMDciIHN0b3AtY29sb3I9IiNmZWZmZmYiIG9mZnNldD0iLjI0NzMxIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTEwOSIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NTAyMyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMWUtNyAtMjYxMy43IC0yNjEzLjcgLTFlLTcgMjY1Mi40IDM5NDIuOCkiPgogICA8c3RvcCBpZD0ic3RvcDUwMjUiIHN0b3AtY29sb3I9IiNhNmQ3MWMiIG9mZnNldD0iMCIvPgogICA8c3RvcCBpZD0ic3RvcDUwMjciIHN0b3AtY29sb3I9IiMyNjgwM2EiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDUwMDUiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDQ1Ni4yMiAwIDAgLTQ1Ni4yMiAyNDI0LjMgMzQ5NC4zKSI+CiAgIDxzdG9wIGlkPSJzdG9wNTAwNyIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTAwOSIgc3RvcC1jb2xvcj0iI2VjZWNlYyIgb2Zmc2V0PSIuNzUyNjkiLz4KICAgPHN0b3AgaWQ9InN0b3A1MDExIiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg2MTA5LTkiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MTExLTQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0ibTIxOTguMiAxOTAuMDNjMCA2Ny4yMzEgMjAzLjMxIDEyMS43NyA0NTQuMTQgMTIxLjc3czQ1NC4xNC01NC41MzkgNDU0LjE0LTEyMS43N2MwLTY3LjMyLTIwMy4zMS0xMjEuODYtNDU0LjE0LTEyMS44NnMtNDU0LjE0IDU0LjU0My00NTQuMTQgMTIxLjg2Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTEzNS03IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTEzNy04IiBkPSJtMjUyOS41IDE1MzEuN2MtMzAuNDYgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnYyMjc4LjhjMjguNDQgMzEuMTggNjcgNTMgMTEwLjMzIDYwLjE0di0yMzM4LjljMC0zMC40Ny0yNC43LTU1LjE2LTU1LjE3LTU1LjE2Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTExNy0wIiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTExOS0zIiBkPSJtMjQ3NC4zIDM4NjUuNnYxODIzLjljMCAzMC40NiAyNC43IDU1LjE2IDU1LjE2IDU1LjE2IDMwLjQ3IDAgNTUuMTctMjQuNyA1NS4xNy01NS4xNnYtMTc2My44Yy00My4zMy03LjE0LTgxLjg5LTI4Ljk2LTExMC4zMy02MC4xNCIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDUwOTctNiIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDUwOTktMyIgZD0ibTI2ODkuMSA1OTQxLjloLTczLjQ0Yy0xNTQuMzcgMC0yNzkuOTYtMTI1LjU5LTI3OS45Ni0yNzkuOTZ2LTQyMDUuNWMtMjE4LjExLTExNi4yMi0zNTYuMDctMzQzLjIyLTM1Ni4wNy01OTMuNyAwLTM3MC45NiAzMDEuNzktNjcyLjc1IDY3Mi43NS02NzIuNzUgMzcwLjk1IDAgNjcyLjc0IDMwMS43OSA2NzIuNzQgNjcyLjc1IDAgMjUwLjQ4LTEzNy45NSA0NzcuNDgtMzU2LjA3IDU5My43djQyMDUuNWMwIDE1NC4zNy0xMjUuNTkgMjc5Ljk2LTI3OS45NSAyNzkuOTZtMC04OC41OGMxMDUuMjYgMCAxOTEuMzgtODYuMTIgMTkxLjM4LTE5MS4zOHYtNDI2MS4yYzIwOS4yOS04OC44NSAzNTYuMDctMjk2LjI1IDM1Ni4wNy01MzcuOTQgMC0zMjIuNjMtMjYxLjU0LTU4NC4xOC01ODQuMTctNTg0LjE4LTMyMi42NCAwLTU4NC4xOCAyNjEuNTUtNTg0LjE4IDU4NC4xOCAwIDI0MS42OSAxNDYuNzkgNDQ5LjA5IDM1Ni4wNyA1MzcuOTR2NDI2MS4yYzAgMTA1LjI2IDg2LjEyIDE5MS4zOCAxOTEuMzkgMTkxLjM4aDczLjQ0Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTA4My01IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTA4NS01IiBkPSJtMjY1Mi40IDk1NS44OWMtMTg5LjE2IDAtMzQyLjUgOTcuMjE5LTM0Mi41IDIxNy4xNSAwIDExOS45MiAxNTMuMzQgMjE3LjE0IDM0Mi41IDIxNy4xNCAxMzkuNjUgMCAyNTkuNzctNTIuOTkgMzEzLjA5LTEyOC45OSAxOC45LTI2Ljk0IDI5LjQtNTYuNzYgMjkuNC04OC4xNSAwLTY2LjY3LTQ3LjQtMTI2LjMzLTEyMS45OS0xNjYuMTYtNTkuNTgtMzEuODEtMTM2LjUxLTUwLjk4OS0yMjAuNS01MC45ODkiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1MDY3LTQiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1MDY5LTciIGQ9Im0yODcyLjkgMTAwNi45Yzc0LjU5IDM5LjgzIDEyMS45OSA5OS40OSAxMjEuOTkgMTY2LjE2di0xLjAyYy0wLjQ4LTY2LjI2LTQ3Ljc2LTEyNS41MS0xMjEuOTktMTY1LjE0bTEyMS45OSAxNjYuMTZjMCAzMS4zOS0xMC41IDYxLjIxLTI5LjQgODguMTUgMTguNy0yNi42NSAyOS4xOC01Ni4xMyAyOS40LTg3LjE0di0xLjAxIi8+CiAgPC9jbGlwUGF0aD4KICA8cmFkaWFsR3JhZGllbnQgaWQ9InJhZGlhbEdyYWRpZW50NTA3MS02IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgY3k9IjAiIGN4PSIwIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDU4NC4xOCAwIDAgLTU4NC4xOCAyNjUyLjQgODYyLjc1KSIgcj0iMSI+CiAgIDxzdG9wIGlkPSJzdG9wNTA3My01IiBzdG9wLWNvbG9yPSIjYTZkNzFjIiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A1MDc1LTYiIHN0b3AtY29sb3I9IiMyNjgwM2EiIG9mZnNldD0iMSIvPgogIDwvcmFkaWFsR3JhZGllbnQ+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDUwNTEtOSIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDUwNTMtMyIgZD0ibTI5OTQuOSAxMTcydjEuMDIgMS4wMS0xLjAxLTEuMDIiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1MDM1LTIiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1MDM3LTUiIGQ9Im0zMjM2LjUgODYyLjc1YzAtMzIyLjY0LTI2MS41NC01ODQuMTgtNTg0LjE3LTU4NC4xOC0zMjIuNjQgMC01ODQuMTggMjYxLjU0LTU4NC4xOCA1ODQuMTggMCAzMjIuNjMgMjYxLjU0IDU4NC4xOCA1ODQuMTggNTg0LjE4IDMyMi42MyAwIDU4NC4xNy0yNjEuNTUgNTg0LjE3LTU4NC4xOCIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDUwMTktNCIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDUwMjEtMyIgZD0ibTI4ODAuNSAxNDAwLjdjLTcwLjA4IDI5Ljc2LTE0Ny4xNyA0Ni4yMi0yMjguMSA0Ni4yMi04MC45NCAwLTE1OC4wMy0xNi40Ni0yMjguMTEtNDYuMjJ2MjMzNi4yYzAgNDkuNDQgMTkgOTQuNjYgNTAuMDYgMTI4Ljcydi0yMjc4LjhjMC0zMC40NyAyNC43LTU1LjE2IDU1LjE2LTU1LjE2IDMwLjQ3IDAgNTUuMTcgMjQuNjkgNTUuMTcgNTUuMTZ2MjMzOC45YzEwLjA5IDEuNjYgMjAuNDUgMi41MiAzMSAyLjUyaDczLjQ0YzEwNS4yNiAwIDE5MS4zOC04Ni4xMiAxOTEuMzgtMTkxLjM4di0yMzM2LjIiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1MDAxLTgiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1MDAzLTYiIGQ9Im0yODgwLjUgMzczNi45YzAgMTA1LjI2LTg2LjEyIDE5MS4zOC0xOTEuMzggMTkxLjM4aC03My40NGMtMTAuNTUgMC0yMC45MS0wLjg2LTMxLTIuNTJ2MTc2My44YzAgMzAuNDYtMjQuNyA1NS4xNi01NS4xNyA1NS4xNi0zMC40NiAwLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTE4MjMuOWMtMzEuMDYtMzQuMDYtNTAuMDYtNzkuMjgtNTAuMDYtMTI4LjcydjE5MjVjMCAxMDUuMjYgODYuMTIgMTkxLjM4IDE5MS4zOSAxOTEuMzhoNzMuNDRjMTA1LjI2IDAgMTkxLjM4LTg2LjEyIDE5MS4zOC0xOTEuMzh2LTE5MjUiLz4KICA8L2NsaXBQYXRoPgogPC9kZWZzPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTI1Ny4wNiAtMjAwLjk3KSI+CiAgPGcgZmlsbD0iI2ZmZiI+CiAgIDxwYXRoIGlkPSJwYXRoNDcxNSIgZD0ibTM0NS41OCA5NDguMTRjLTQ4Ljg5IDAtODguNTIyLTEwLjYzMi04OC41MjItMjMuNzUgMC03LjA4NjQgMTEuNTcxLTEzLjQ0OSAyOS45MTktMTcuOCAwLjAzNSAwLjAyOTggMC4wNjc1IDAuMDU4NSAwLjEwMjUgMC4wODk3LTE4LjIyIDQuMzM1LTI5LjcgMTAuNjYzLTI5LjcgMTcuNzEgMCAxMy4wNjkgMzkuNDg5IDIzLjY2NCA4OC4yMDEgMjMuNjY0IDQ4LjcxMSAwIDg4LjIwMS0xMC41OTUgODguMjAxLTIzLjY2NCAwLTcuMDQ3NC0xMS40ODEtMTMuMzc1LTI5LjcwMS0xNy43MSAwLjAzNS0wLjAzMTIgMC4wNjctMC4wNiAwLjEwMjUtMC4wODk3IDE4LjM0OCA0LjM1MSAyOS45MTkgMTAuNzEzIDI5LjkxOSAxNy44IDAgMTMuMTE4LTM5LjYzMiAyMy43NS04OC41MjEgMjMuNzUiLz4KICAgPHBhdGggaWQ9InBhdGg0NzE3IiBkPSJtMzQ1LjU4IDk0OC4wNWMtNDguNzEyIDAtODguMjAxLTEwLjU5NS04OC4yMDEtMjMuNjY0IDAtNy4wNDc0IDExLjQ4LTEzLjM3NSAyOS43LTE3LjcxIDAuMDMzNyAwLjAyODkgMC4wNyAwLjA2MTEgMC4xMDI1IDAuMDkxNC0xOC4wODkgNC4zMTc0LTI5LjQ4MiAxMC42MTEtMjkuNDgyIDE3LjYxOSAwIDEzLjAyMSAzOS4zNDYgMjMuNTc4IDg3Ljg4MSAyMy41NzhzODcuODgtMTAuNTU3IDg3Ljg4LTIzLjU3OGMwLTcuMDA3NC0xMS4zOTQtMTMuMzAxLTI5LjQ4Mi0xNy42MTkgMC4wMzMtMC4wMzAyIDAuMDctMC4wNjI1IDAuMTAyNS0wLjA5MTQgMTguMjIgNC4zMzUgMjkuNzAxIDEwLjY2MyAyOS43MDEgMTcuNzEgMCAxMy4wNjktMzkuNDkgMjMuNjY0LTg4LjIwMSAyMy42NjQiLz4KICAgPHBhdGggaWQ9InBhdGg0NzE5IiBkPSJtMzQ1LjU4IDk0Ny45NmMtNDguNTM1IDAtODcuODgxLTEwLjU1Ny04Ny44ODEtMjMuNTc4IDAtNy4wMDc0IDExLjM5NC0xMy4zMDEgMjkuNDgyLTE3LjYxOSAwLjAzNjIgMC4wMjk3IDAuMDY4NyAwLjA1ODYgMC4xMDM3NSAwLjA4OTktMTcuOTYgNC4zMDI2LTI5LjI2NSAxMC41NjItMjkuMjY1IDE3LjUyOSAwIDEyLjk3NCAzOS4yMDEgMjMuNDkxIDg3LjU2IDIzLjQ5MSA0OC4zNTggMCA4Ny41NTktMTAuNTE4IDg3LjU1OS0yMy40OTEgMC02Ljk2NzItMTEuMzA1LTEzLjIyNi0yOS4yNjUtMTcuNTI5IDAuMDM2LTAuMDMxMiAwLjA2OS0wLjA2MDEgMC4xMDM4LTAuMDg5OSAxOC4wODkgNC4zMTc0IDI5LjQ4MiAxMC42MTEgMjkuNDgyIDE3LjYxOSAwIDEzLjAyMS0zOS4zNDUgMjMuNTc4LTg3Ljg4IDIzLjU3OCIvPgogICA8cGF0aCBpZD0icGF0aDQ3MjEiIGQ9Im0zNDUuNTggOTQ3Ljg4Yy00OC4zNTkgMC04Ny41Ni0xMC41MTgtODcuNTYtMjMuNDkxIDAtNi45NjcyIDExLjMwNS0xMy4yMjYgMjkuMjY1LTE3LjUyOSAwLjAzMjUgMC4wMjg4IDAuMDcgMC4wNjEgMC4xMDM3NSAwLjA4OTctMTcuODMgNC4yODY2LTI5LjA0OCAxMC41MTEtMjkuMDQ4IDE3LjQzOSAwIDEyLjkyNiAzOS4wNTggMjMuNDA2IDg3LjIzOSAyMy40MDYgNDguMTggMCA4Ny4yMzktMTAuNDggODcuMjM5LTIzLjQwNiAwLTYuOTI3OC0xMS4yMTktMTMuMTUyLTI5LjA0OC0xNy40MzkgMC4wMzMtMC4wMjg3IDAuMDctMC4wNjEgMC4xMDI1LTAuMDg5NyAxNy45NiA0LjMwMjYgMjkuMjY1IDEwLjU2MiAyOS4yNjUgMTcuNTI5IDAgMTIuOTc0LTM5LjIwMSAyMy40OTEtODcuNTU5IDIzLjQ5MSIvPgogICA8cGF0aCBpZD0icGF0aDQ3MjMiIGQ9Im0zNDUuNTggOTQ3Ljc5Yy00OC4xODEgMC04Ny4yMzktMTAuNDgtODcuMjM5LTIzLjQwNiAwLTYuOTI3OCAxMS4yMTgtMTMuMTUyIDI5LjA0OC0xNy40MzkgMC4wMzUgMC4wMzEyIDAuMDY3NSAwLjA2MDEgMC4xMDM3NSAwLjA5MTQtMTcuNzAxIDQuMjY5LTI4LjgzMSAxMC40NTktMjguODMxIDE3LjM0OCAwIDEyLjg3OSAzOC45MTUgMjMuMzIgODYuOTE5IDIzLjMyIDQ4LjAwMyAwIDg2LjkxOC0xMC40NDEgODYuOTE4LTIzLjMyIDAtNi44ODg2LTExLjEzLTEzLjA3OS0yOC44MzEtMTcuMzQ4IDAuMDM2LTAuMDMxMiAwLjA2OS0wLjA2MDEgMC4xMDUtMC4wOTE0IDE3LjgyOSA0LjI4NjYgMjkuMDQ4IDEwLjUxMSAyOS4wNDggMTcuNDM5IDAgMTIuOTI2LTM5LjA1OSAyMy40MDYtODcuMjM5IDIzLjQwNiIvPgogICA8cGF0aCBpZD0icGF0aDQ3MjUiIGQ9Im0zNDUuNTggOTQ3LjcxYy00OC4wMDQgMC04Ni45MTktMTAuNDQxLTg2LjkxOS0yMy4zMiAwLTYuODg4NiAxMS4xMy0xMy4wNzkgMjguODMxLTE3LjM0OCAwLjAzNSAwLjAzMTIgMC4wNjg3IDAuMDYgMC4xMDM3NSAwLjA5MDItMTcuNTY5IDQuMjUyNi0yOC42MTQgMTAuNDEtMjguNjE0IDE3LjI1NyAwIDEyLjgzMSAzOC43NyAyMy4yMzQgODYuNTk4IDIzLjIzNCA0Ny44MjYgMCA4Ni41OTgtMTAuNDAzIDg2LjU5OC0yMy4yMzQgMC02Ljg0NzYtMTEuMDQ2LTEzLjAwNS0yOC42MTUtMTcuMjU3IDAuMDM1LTAuMDMwMyAwLjA2OS0wLjA1OSAwLjEwMzctMC4wOTAyIDE3LjcwMSA0LjI2OSAyOC44MzEgMTAuNDU5IDI4LjgzMSAxNy4zNDggMCAxMi44NzktMzguOTE1IDIzLjMyLTg2LjkxOCAyMy4zMiIvPgogICA8cGF0aCBpZD0icGF0aDQ3MjciIGQ9Im0zNDUuNTggOTQ3LjYyYy00Ny44MjggMC04Ni41OTgtMTAuNDAzLTg2LjU5OC0yMy4yMzQgMC02Ljg0NzYgMTEuMDQ1LTEzLjAwNSAyOC42MTQtMTcuMjU3IDAuMDMzNyAwLjAyOTkgMC4wNzEyIDAuMDYyNSAwLjEwNSAwLjA5MTQtMTcuNDQxIDQuMjM0OS0yOC4zOTkgMTAuMzU3LTI4LjM5OSAxNy4xNjYgMCAxMi43ODQgMzguNjI4IDIzLjE0NyA4Ni4yNzggMjMuMTQ3IDQ3LjY0OSAwIDg2LjI3Ni0xMC4zNjQgODYuMjc2LTIzLjE0NyAwLTYuODA4Ni0xMC45NTgtMTIuOTMxLTI4LjM5OS0xNy4xNjYgMC4wMzQtMC4wMjg5IDAuMDcxLTAuMDYxNSAwLjEwNS0wLjA5MTQgMTcuNTY5IDQuMjUyNSAyOC42MTUgMTAuNDEgMjguNjE1IDE3LjI1NyAwIDEyLjgzMS0zOC43NzEgMjMuMjM0LTg2LjU5OCAyMy4yMzQiLz4KICAgPHBhdGggaWQ9InBhdGg0NzI5IiBkPSJtMzQ1LjU4IDk0Ny41M2MtNDcuNjUgMC04Ni4yNzgtMTAuMzY0LTg2LjI3OC0yMy4xNDcgMC02LjgwODYgMTAuOTU4LTEyLjkzMSAyOC4zOTktMTcuMTY2IDAuMDM1IDAuMDMwOCAwLjA2ODcgMC4wNTk1IDAuMTA1IDAuMDkwOC0xNy4zMTEgNC4yMTg3LTI4LjE4MiAxMC4zMDctMjguMTgyIDE3LjA3NSAwIDEyLjczNiAzOC40ODQgMjMuMDYxIDg1Ljk1NiAyMy4wNjEgNDcuNDcxIDAgODUuOTU1LTEwLjMyNSA4NS45NTUtMjMuMDYxIDAtNi43Njg1LTEwLjg2OC0xMi44NTYtMjguMTgxLTE3LjA3NSAwLjAzNS0wLjAzMTIgMC4wNjktMC4wNiAwLjEwMzgtMC4wOTA4IDE3LjQ0MSA0LjIzNDkgMjguMzk5IDEwLjM1NyAyOC4zOTkgMTcuMTY2IDAgMTIuNzg0LTM4LjYyOCAyMy4xNDctODYuMjc2IDIzLjE0NyIvPgogICA8cGF0aCBpZD0icGF0aDQ3MzEiIGQ9Im0zNDUuNTggOTQ3LjQ1Yy00Ny40NzIgMC04NS45NTYtMTAuMzI1LTg1Ljk1Ni0yMy4wNjEgMC02Ljc2ODUgMTAuODcxLTEyLjg1NiAyOC4xODItMTcuMDc1IDAuMDM1IDAuMDMwNCAwLjA2ODcgMC4wNjAxIDAuMTA1IDAuMDkwNC0xNy4xODQgNC4yMDIxLTI3Ljk2NiAxMC4yNTYtMjcuOTY2IDE2Ljk4NSAwIDEyLjY4OCAzOC4zNCAyMi45NzYgODUuNjM1IDIyLjk3NnM4NS42MzUtMTAuMjg4IDg1LjYzNS0yMi45NzZjMC02LjcyOS0xMC43ODQtMTIuNzgzLTI3Ljk2OC0xNi45ODUgMC4wMzYtMC4wMzAzIDAuMDctMC4wNiAwLjEwNjItMC4wOTA0IDE3LjMxNCA0LjIxODggMjguMTgxIDEwLjMwNyAyOC4xODEgMTcuMDc1IDAgMTIuNzM2LTM4LjQ4NCAyMy4wNjEtODUuOTU1IDIzLjA2MSIvPgogICA8cGF0aCBpZD0icGF0aDQ3MzMiIGQ9Im0zNDUuNTggOTQ3LjM2Yy00Ny4yOTUgMC04NS42MzUtMTAuMjg4LTg1LjYzNS0yMi45NzYgMC02LjcyOSAxMC43ODItMTIuNzgzIDI3Ljk2Ni0xNi45ODUgMC4wMzM3IDAuMDI4OSAwLjA3MTIgMC4wNjI1IDAuMTA1IDAuMDkxNC0xNy4wNTQgNC4xODQ1LTI3Ljc1MSAxMC4yMDQtMjcuNzUxIDE2Ljg5MyAwIDEyLjY0MSAzOC4xOTYgMjIuODkgODUuMzE1IDIyLjg5IDQ3LjExOCAwIDg1LjMxNC0xMC4yNDkgODUuMzE0LTIyLjg5IDAtNi42ODktMTAuNjk4LTEyLjcwOS0yNy43NTEtMTYuODk0IDAuMDM0LTAuMDI4OSAwLjA3Mi0wLjA2MjUgMC4xMDUtMC4wOTE0IDE3LjE4NCA0LjIwMjEgMjcuOTY4IDEwLjI1NiAyNy45NjggMTYuOTg1IDAgMTIuNjg4LTM4LjM0IDIyLjk3Ni04NS42MzUgMjIuOTc2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDczNSIgZD0ibTM0NS41OCA5NDcuMjhjLTQ3LjExOSAwLTg1LjMxNS0xMC4yNDktODUuMzE1LTIyLjg5IDAtNi42ODkgMTAuNjk4LTEyLjcwOSAyNy43NTEtMTYuODk0IDAuMDM2MiAwLjAyOTggMC4wNyAwLjA1OTUgMC4xMDYyNSAwLjA4OTctMTYuOTI1IDQuMTY5LTI3LjUzNiAxMC4xNTQtMjcuNTM2IDE2LjgwNCAwIDEyLjU5NCAzOC4wNTIgMjIuODA0IDg0Ljk5NCAyMi44MDQgNDYuOTQgMCA4NC45OTQtMTAuMjEgODQuOTk0LTIyLjgwNCAwLTYuNjQ5OS0xMC42MTItMTIuNjM1LTI3LjUzOC0xNi44MDQgMC4wMzYtMC4wMzAzIDAuMDctMC4wNiAwLjEwNjMtMC4wODk3IDE3LjA1NCA0LjE4NDUgMjcuNzUxIDEwLjIwNCAyNy43NTEgMTYuODk0IDAgMTIuNjQxLTM4LjE5NiAyMi44OS04NS4zMTQgMjIuODkiLz4KICAgPHBhdGggaWQ9InBhdGg0NzM3IiBkPSJtMzQ1LjU4IDk0Ny4xOWMtNDYuOTQxIDAtODQuOTk0LTEwLjIxLTg0Ljk5NC0yMi44MDQgMC02LjY0OTkgMTAuNjExLTEyLjYzNSAyNy41MzYtMTYuODA0IDAuMDM1IDAuMDMxMiAwLjA3IDAuMDYwMSAwLjEwNjI1IDAuMDkxNC0xNi43OTYgNC4xNTE0LTI3LjMyMiAxMC4xMDItMjcuMzIyIDE2LjcxMiAwIDEyLjU0NiAzNy45MSAyMi43MTcgODQuNjc0IDIyLjcxNyA0Ni43NjMgMCA4NC42NzMtMTAuMTcxIDg0LjY3My0yMi43MTcgMC02LjYwOTktMTAuNTI2LTEyLjU2MS0yNy4zMjItMTYuNzEyIDAuMDM2LTAuMDMxMiAwLjA3MS0wLjA2MDEgMC4xMDYyLTAuMDkxNCAxNi45MjUgNC4xNjkgMjcuNTM4IDEwLjE1NCAyNy41MzggMTYuODA0IDAgMTIuNTk0LTM4LjA1NCAyMi44MDQtODQuOTk0IDIyLjgwNCIvPgogICA8cGF0aCBpZD0icGF0aDQ3MzkiIGQ9Im0zNDUuNTggOTQ3LjFjLTQ2Ljc2NCAwLTg0LjY3NC0xMC4xNzEtODQuNjc0LTIyLjcxNyAwLTYuNjA5OSAxMC41MjYtMTIuNTYxIDI3LjMyMi0xNi43MTIgMC4wMzYyIDAuMDI5NyAwLjA3IDAuMDYgMC4xMDYyNSAwLjA4OTktMTYuNjY5IDQuMTM1Mi0yNy4xMDggMTAuMDUzLTI3LjEwOCAxNi42MjIgMCAxMi40OTkgMzcuNzY2IDIyLjYzMSA4NC4zNTIgMjIuNjMxczg0LjM1MS0xMC4xMzMgODQuMzUxLTIyLjYzMWMwLTYuNTY5OS0xMC40MzktMTIuNDg3LTI3LjEwOC0xNi42MjIgMC4wMzYtMC4wMjk5IDAuMDcxLTAuMDYwMSAwLjEwNjMtMC4wODk5IDE2Ljc5NiA0LjE1MTQgMjcuMzIyIDEwLjEwMiAyNy4zMjIgMTYuNzEyIDAgMTIuNTQ2LTM3LjkxIDIyLjcxNy04NC42NzMgMjIuNzE3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDc0MSIgZD0ibTM0NS41OCA5NDcuMDJjLTQ2LjU4NiAwLTg0LjM1Mi0xMC4xMzMtODQuMzUyLTIyLjYzMSAwLTYuNTY5OSAxMC40MzktMTIuNDg3IDI3LjEwOC0xNi42MjIgMC4wMzYyIDAuMDMxMiAwLjA3MTIgMC4wNjE1IDAuMTA3NSAwLjA5MTMtMTYuNTQxIDQuMTE3Ni0yNi44OTQgMTAtMjYuODk0IDE2LjUzMSAwIDEyLjQ1MSAzNy42MjEgMjIuNTQ1IDg0LjAzMSAyMi41NDUgNDYuNDA5IDAgODQuMDMxLTEwLjA5NCA4NC4wMzEtMjIuNTQ1IDAtNi41MzEyLTEwLjM1NC0xMi40MTQtMjYuODk1LTE2LjUzMSAwLjAzNi0wLjAyOTggMC4wNzEtMC4wNiAwLjEwNzUtMC4wOTEzIDE2LjY2OSA0LjEzNTIgMjcuMTA4IDEwLjA1MyAyNy4xMDggMTYuNjIyIDAgMTIuNDk5LTM3Ljc2NSAyMi42MzEtODQuMzUxIDIyLjYzMSIvPgogICA8cGF0aCBpZD0icGF0aDQ3NDMiIGQ9Im0zNDUuNTggOTQ2LjkzYy00Ni40MSAwLTg0LjAzMS0xMC4wOTQtODQuMDMxLTIyLjU0NSAwLTYuNTMxMiAxMC4zNTItMTIuNDE0IDI2Ljg5NC0xNi41MzEgMC4wMzUgMC4wMjk3IDAuMDcxMiAwLjA2IDAuMTA3NSAwLjA4OTktMTYuNDE1IDQuMTAxNS0yNi42ODEgOS45NTAxLTI2LjY4MSAxNi40NDEgMCAxMi40MDQgMzcuNDc5IDIyLjQ1OSA4My43MTEgMjIuNDU5IDQ2LjIzMSAwIDgzLjcxLTEwLjA1NSA4My43MS0yMi40NTkgMC02LjQ5MTItMTAuMjY2LTEyLjM0LTI2LjY4LTE2LjQ0MSAwLjAzNS0wLjAyOTkgMC4wNzEtMC4wNjAxIDAuMTA2Mi0wLjA4OTkgMTYuNTQxIDQuMTE3NiAyNi44OTUgMTAgMjYuODk1IDE2LjUzMSAwIDEyLjQ1MS0zNy42MjIgMjIuNTQ1LTg0LjAzMSAyMi41NDUiLz4KICAgPHBhdGggaWQ9InBhdGg0NzQ1IiBkPSJtMzQ1LjU4IDk0Ni44NGMtNDYuMjMyIDAtODMuNzExLTEwLjA1NS04My43MTEtMjIuNDU5IDAtNi40OTEyIDEwLjI2Ni0xMi4zNCAyNi42ODEtMTYuNDQxIDAuMDM1IDAuMDMxMiAwLjA3MTIgMC4wNjE1IDAuMTA3NSAwLjA5MTItMTYuMjg5IDQuMDg0LTI2LjQ2OCA5Ljg5NzUtMjYuNDY4IDE2LjM1IDAgMTIuMzU2IDM3LjMzNSAyMi4zNzMgODMuMzkgMjIuMzczczgzLjM4OS0xMC4wMTYgODMuMzg5LTIyLjM3M2MwLTYuNDUyNi0xMC4xNzktMTIuMjY2LTI2LjQ2Ni0xNi4zNSAwLjAzNS0wLjAyOTcgMC4wNzEtMC4wNiAwLjEwNzUtMC4wOTEyIDE2LjQxNCA0LjEwMTUgMjYuNjggOS45NTAxIDI2LjY4IDE2LjQ0MSAwIDEyLjQwNC0zNy40NzkgMjIuNDU5LTgzLjcxIDIyLjQ1OSIvPgogICA8cGF0aCBpZD0icGF0aDQ3NDciIGQ9Im0zNDUuNTggOTQ2Ljc2Yy00Ni4wNTUgMC04My4zOS0xMC4wMTYtODMuMzktMjIuMzczIDAtNi40NTI2IDEwLjE3OS0xMi4yNjYgMjYuNDY4LTE2LjM1IDAuMDM1IDAuMDMwMyAwLjA3MTIgMC4wNjAxIDAuMTA3NSAwLjA5MTQtMTYuMTU5IDQuMDY2NC0yNi4yNTQgOS44NDYxLTI2LjI1NCAxNi4yNTkgMCAxMi4zMDkgMzcuMTkgMjIuMjg4IDgzLjA2OSAyMi4yODggNDUuODc4IDAgODMuMDY5LTkuOTc5IDgzLjA2OS0yMi4yODggMC02LjQxMjYtMTAuMDk2LTEyLjE5Mi0yNi4yNTUtMTYuMjU5IDAuMDM2LTAuMDMxMiAwLjA3Mi0wLjA2MTEgMC4xMDg4LTAuMDkxNCAxNi4yODggNC4wODQgMjYuNDY2IDkuODk3NSAyNi40NjYgMTYuMzUgMCAxMi4zNTYtMzcuMzM0IDIyLjM3My04My4zODkgMjIuMzczIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc0OSIgZD0ibTM0NS41OCA5NDYuNjdjLTQ1Ljg3OSAwLTgzLjA2OS05Ljk3OS04My4wNjktMjIuMjg4IDAtNi40MTI2IDEwLjA5NS0xMi4xOTIgMjYuMjU0LTE2LjI1OSAwLjAzNjIgMC4wMzAzIDAuMDcyNSAwLjA2IDAuMTA4NzUgMC4wODk4LTE2LjAzMSA0LjA0ODktMjYuMDQyIDkuNzk2NS0yNi4wNDIgMTYuMTY5IDAgMTIuMjYxIDM3LjA0OCAyMi4yMDEgODIuNzQ5IDIyLjIwMSA0NS43IDAgODIuNzQ4LTkuOTQgODIuNzQ4LTIyLjIwMSAwLTYuMzcyNS0xMC4wMTEtMTIuMTItMjYuMDQyLTE2LjE2OSAwLjAzNi0wLjAyOTggMC4wNzItMC4wNTk1IDAuMTA4Ny0wLjA4OTggMTYuMTU5IDQuMDY2NCAyNi4yNTUgOS44NDYxIDI2LjI1NSAxNi4yNTkgMCAxMi4zMDktMzcuMTkxIDIyLjI4OC04My4wNjkgMjIuMjg4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDc1MSIgZD0ibTM0NS41OCA5NDYuNTljLTQ1LjcwMSAwLTgyLjc0OS05Ljk0LTgyLjc0OS0yMi4yMDEgMC02LjM3MjUgMTAuMDExLTEyLjEyIDI2LjA0Mi0xNi4xNjkgMC4wMzYzIDAuMDMwNCAwLjA3MjUgMC4wNjE2IDAuMTA4NzUgMC4wOTE0LTE1LjkwNiA0LjAzMTItMjUuODMgOS43NDQxLTI1LjgzIDE2LjA3OCAwIDEyLjIxNCAzNi45MDQgMjIuMTE1IDgyLjQyOCAyMi4xMTUgNDUuNTIzIDAgODIuNDI2LTkuOTAwOSA4Mi40MjYtMjIuMTE1IDAtNi4zMzM1LTkuOTIzNy0xMi4wNDYtMjUuODMtMTYuMDc4IDAuMDM2LTAuMDI5OCAwLjA3NC0wLjA2MSAwLjEwODgtMC4wOTE0IDE2LjAzMSA0LjA0ODkgMjYuMDQyIDkuNzk2NSAyNi4wNDIgMTYuMTY5IDAgMTIuMjYxLTM3LjA0OCAyMi4yMDEtODIuNzQ4IDIyLjIwMSIvPgogICA8cGF0aCBpZD0icGF0aDQ3NTMiIGQ9Im0zNDUuNTggOTQ2LjVjLTQ1LjUyNCAwLTgyLjQyOC05LjkwMDktODIuNDI4LTIyLjExNSAwLTYuMzMzNSA5LjkyMzgtMTIuMDQ2IDI1LjgzLTE2LjA3OCAwLjAzNjIgMC4wMzAzIDAuMDcyNSAwLjA2MTUgMC4xMDg3NSAwLjA5MTMtMTUuNzc5IDQuMDEzNy0yNS42MTkgOS42OTE0LTI1LjYxOSAxNS45ODYgMCAxMi4xNjYgMzYuNzYxIDIyLjAyOSA4Mi4xMDggMjIuMDI5IDQ1LjM0NiAwIDgyLjEwNi05Ljg2MjggODIuMTA2LTIyLjAyOSAwLTYuMjk0OS05Ljg0LTExLjk3My0yNS42MTktMTUuOTg2IDAuMDM2LTAuMDI5NyAwLjA3Mi0wLjA2MSAwLjEwODctMC4wOTEzIDE1LjkwNiA0LjAzMTIgMjUuODMgOS43NDQxIDI1LjgzIDE2LjA3OCAwIDEyLjIxNC0zNi45MDQgMjIuMTE1LTgyLjQyNiAyMi4xMTUiLz4KICAgPHBhdGggaWQ9InBhdGg0NzU1IiBkPSJtMzQ1LjU4IDk0Ni40MWMtNDUuMzQ2IDAtODIuMTA4LTkuODYyOC04Mi4xMDgtMjIuMDI5IDAtNi4yOTQ5IDkuODQtMTEuOTczIDI1LjYxOS0xNS45ODYgMC4wMzYzIDAuMDMwNCAwLjA3MzggMC4wNjAxIDAuMTEgMC4wODk5LTE1LjY1MiAzLjk5NzYtMjUuNDA4IDkuNjQxNi0yNS40MDggMTUuODk2IDAgMTIuMTE5IDM2LjYxNiAyMS45NDIgODEuNzg2IDIxLjk0MiA0NS4xNjkgMCA4MS43ODUtOS44MjM3IDgxLjc4NS0yMS45NDIgMC02LjI1NDktOS43NTM4LTExLjg5OS0yNS40MDYtMTUuODk2IDAuMDM2LTAuMDI5NyAwLjA3Mi0wLjA1OTUgMC4xMDg4LTAuMDg5OSAxNS43NzkgNC4wMTM4IDI1LjYxOSA5LjY5MTUgMjUuNjE5IDE1Ljk4NiAwIDEyLjE2Ni0zNi43NiAyMi4wMjktODIuMTA2IDIyLjAyOSIvPgogICA8cGF0aCBpZD0icGF0aDQ3NTciIGQ9Im0zNDUuNTggOTQ2LjMzYy00NS4xNyAwLTgxLjc4Ni05LjgyMzctODEuNzg2LTIxLjk0MiAwLTYuMjU0OSA5Ljc1NS0xMS44OTkgMjUuNDA4LTE1Ljg5NiAwLjAzNjIgMC4wMzAzIDAuMDcyNSAwLjA2MTUgMC4xMDg3NSAwLjA5MTQtMTUuNTI2IDMuOTc5OS0yNS4xOTUgOS41ODg5LTI1LjE5NSAxNS44MDUgMCAxMi4wNzEgMzYuNDcyIDIxLjg1NiA4MS40NjUgMjEuODU2IDQ0Ljk5MSAwIDgxLjQ2NS05Ljc4NTIgODEuNDY1LTIxLjg1NiAwLTYuMjE2Mi05LjY3LTExLjgyNS0yNS4xOTYtMTUuODA1IDAuMDM2LTAuMDI5OSAwLjA3NC0wLjA2MTEgMC4xMS0wLjA5MTQgMTUuNjUyIDMuOTk3NiAyNS40MDYgOS42NDE2IDI1LjQwNiAxNS44OTYgMCAxMi4xMTktMzYuNjE2IDIxLjk0Mi04MS43ODUgMjEuOTQyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc1OSIgZD0ibTM0NS41OCA5NDYuMjRjLTQ0Ljk5MiAwLTgxLjQ2NS05Ljc4NTItODEuNDY1LTIxLjg1NiAwLTYuMjE2MiA5LjY2ODgtMTEuODI1IDI1LjE5NS0xNS44MDUgMC4wMzYzIDAuMDMwMiAwLjA3NSAwLjA2MTUgMC4xMTEyNSAwLjA5MTItMTUuMzk5IDMuOTYxNC0yNC45ODYgOS41Mzc3LTI0Ljk4NiAxNS43MTQgMCAxMi4wMjQgMzYuMzMgMjEuNzcgODEuMTQ1IDIxLjc3IDQ0LjgxNCAwIDgxLjE0NC05Ljc0NjEgODEuMTQ0LTIxLjc3IDAtNi4xNzYyLTkuNTg3NS0xMS43NTItMjQuOTg2LTE1LjcxNCAwLjAzOC0wLjAyOTcgMC4wNzUtMC4wNjEgMC4xMTEyLTAuMDkxMiAxNS41MjYgMy45Nzk5IDI1LjE5NiA5LjU4ODkgMjUuMTk2IDE1LjgwNSAwIDEyLjA3MS0zNi40NzQgMjEuODU2LTgxLjQ2NSAyMS44NTYiLz4KICAgPHBhdGggaWQ9InBhdGg0NzYxIiBkPSJtMzQ1LjU4IDk0Ni4xNmMtNDQuODE1IDAtODEuMTQ1LTkuNzQ2MS04MS4xNDUtMjEuNzcgMC02LjE3NjIgOS41ODc1LTExLjc1MiAyNC45ODYtMTUuNzE0IDAuMDM2MiAwLjAyODkgMC4wNzM3IDAuMDYwMSAwLjExIDAuMDkwNC0xNS4yNzIgMy45NDQ4LTI0Ljc3NSA5LjQ4NTgtMjQuNzc1IDE1LjYyNCAwIDExLjk3NiAzNi4xODYgMjEuNjg1IDgwLjgyNCAyMS42ODVzODAuODIzLTkuNzA5IDgwLjgyMy0yMS42ODVjMC02LjEzNzgtOS41MDI1LTExLjY3OS0yNC43NzUtMTUuNjI0IDAuMDM2LTAuMDMwMyAwLjA3NC0wLjA2MTUgMC4xMS0wLjA5MDQgMTUuMzk5IDMuOTYxNCAyNC45ODYgOS41Mzc2IDI0Ljk4NiAxNS43MTQgMCAxMi4wMjQtMzYuMzMgMjEuNzctODEuMTQ0IDIxLjc3Ii8+CiAgPC9nPgogIDxnPgogICA8cGF0aCBpZD0icGF0aDQ3NjMiIGQ9Im0zNDUuNTggOTQ2LjA3Yy00NC42MzggMC04MC44MjQtOS43MDktODAuODI0LTIxLjY4NSAwLTYuMTM3OCA5LjUwMjUtMTEuNjc5IDI0Ljc3NS0xNS42MjQgMC4wMzg4IDAuMDMwOCAwLjA3MjUgMC4wNTk1IDAuMTExMjUgMC4wOTA3LTE1LjE0NiAzLjkyNzgtMjQuNTY2IDkuNDM1MS0yNC41NjYgMTUuNTMzIDAgMTEuOTI5IDM2LjA0MiAyMS41OTkgODAuNTA0IDIxLjU5OSA0NC40NiAwIDgwLjUwMy05LjY2OTkgODAuNTAzLTIxLjU5OSAwLTYuMDk3Ni05LjQyLTExLjYwNS0yNC41NjYtMTUuNTMzIDAuMDM5LTAuMDMxMiAwLjA3Mi0wLjA2IDAuMTExMy0wLjA5MDcgMTUuMjcyIDMuOTQ0OCAyNC43NzUgOS40ODU4IDI0Ljc3NSAxNS42MjQgMCAxMS45NzYtMzYuMTg1IDIxLjY4NS04MC44MjMgMjEuNjg1IiBmaWxsPSIjZmVmZmZmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc2NSIgZD0ibTM0NS41OCA5NDUuOThjLTQ0LjQ2MSAwLTgwLjUwNC05LjY2OTktODAuNTA0LTIxLjU5OSAwLTYuMDk3NiA5LjQyLTExLjYwNSAyNC41NjYtMTUuNTMzIDAuMDM2MiAwLjAzMDMgMC4wNzUgMC4wNjE1IDAuMTExMjUgMC4wOTE0LTE1LjAyMiAzLjkwODYtMjQuMzU2IDkuMzgyNy0yNC4zNTYgMTUuNDQxIDAgMTEuODgxIDM1Ljg5OSAyMS41MTMgODAuMTgyIDIxLjUxM3M4MC4xODEtOS42MzE0IDgwLjE4MS0yMS41MTNjMC02LjA1ODYtOS4zMzM3LTExLjUzMy0yNC4zNTYtMTUuNDQxIDAuMDM2LTAuMDI5OSAwLjA3NS0wLjA2MTEgMC4xMTEyLTAuMDkxNCAxNS4xNDYgMy45Mjc4IDI0LjU2NiA5LjQzNTEgMjQuNTY2IDE1LjUzMyAwIDExLjkyOS0zNi4wNDIgMjEuNTk5LTgwLjUwMyAyMS41OTkiIGZpbGw9IiNmZWZmZmYiLz4KICAgPHBhdGggaWQ9InBhdGg0NzY3IiBkPSJtMzQ1LjU4IDk0NS45Yy00NC4yODQgMC04MC4xODItOS42MzE0LTgwLjE4Mi0yMS41MTMgMC02LjA1ODYgOS4zMzM4LTExLjUzMyAyNC4zNTYtMTUuNDQxIDAuMDM2MiAwLjAyODcgMC4wNzUgMC4wNjE1IDAuMTExMjUgMC4wOTAyLTE0Ljg5NSAzLjg5MjEtMjQuMTQ2IDkuMzMyNi0yNC4xNDYgMTUuMzUxIDAgMTEuODM0IDM1Ljc1NSAyMS40MjYgNzkuODYxIDIxLjQyNnM3OS44NjEtOS41OTIzIDc5Ljg2MS0yMS40MjZjMC02LjAxODUtOS4yNTEyLTExLjQ1OS0yNC4xNDgtMTUuMzUxIDAuMDM2LTAuMDI4NyAwLjA3NS0wLjA2MTUgMC4xMTEzLTAuMDkwMiAxNS4wMjIgMy45MDg2IDI0LjM1NiA5LjM4MjggMjQuMzU2IDE1LjQ0MSAwIDExLjg4MS0zNS44OTkgMjEuNTEzLTgwLjE4MSAyMS41MTMiIGZpbGw9IiNmZWZlZmYiLz4KICAgPHBhdGggaWQ9InBhdGg0NzY5IiBkPSJtMzQ1LjU4IDk0NS44MWMtNDQuMTA2IDAtNzkuODYxLTkuNTkyMy03OS44NjEtMjEuNDI2IDAtNi4wMTg1IDkuMjUxMi0xMS40NTkgMjQuMTQ2LTE1LjM1MSAwLjAzODcgMC4wMzEyIDAuMDczOCAwLjA2MDEgMC4xMTI1IDAuMDkwOS0xNC43NyAzLjg3NTUtMjMuOTM5IDkuMjgwMi0yMy45MzkgMTUuMjYgMCAxMS43ODYgMzUuNjExIDIxLjM0IDc5LjU0MSAyMS4zNCA0My45MjkgMCA3OS41NC05LjU1MzcgNzkuNTQtMjEuMzQgMC01Ljk4LTkuMTY3NS0xMS4zODUtMjMuOTM5LTE1LjI2IDAuMDM5LTAuMDMwOCAwLjA3NC0wLjA1OTYgMC4xMTI1LTAuMDkwOSAxNC44OTYgMy44OTIxIDI0LjE0OCA5LjMzMjYgMjQuMTQ4IDE1LjM1MSAwIDExLjgzNC0zNS43NTUgMjEuNDI2LTc5Ljg2MSAyMS40MjYiIGZpbGw9IiNmZWZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg0NzcxIiBkPSJtMzQ1LjU4IDk0NS43M2MtNDMuOTMgMC03OS41NDEtOS41NTM3LTc5LjU0MS0yMS4zNCAwLTUuOTggOS4xNjg4LTExLjM4NSAyMy45MzktMTUuMjYgMC4wMzYyIDAuMDMwMyAwLjA3NjIgMC4wNjE1IDAuMTEyNSAwLjA5MTMtMTQuNjQ1IDMuODU2NS0yMy43MyA5LjIyNzYtMjMuNzMgMTUuMTY5IDAgMTEuNzM5IDM1LjQ2OCAyMS4yNTUgNzkuMjIgMjEuMjU1IDQzLjc1MSAwIDc5LjIxOS05LjUxNjEgNzkuMjE5LTIxLjI1NSAwLTUuOTQxNC05LjA4MzctMTEuMzEyLTIzLjczLTE1LjE2OSAwLjAzNi0wLjAyOTggMC4wNzYtMC4wNjEgMC4xMTI1LTAuMDkxMyAxNC43NzEgMy44NzU1IDIzLjkzOSA5LjI4MDIgMjMuOTM5IDE1LjI2IDAgMTEuNzg2LTM1LjYxMSAyMS4zNC03OS41NCAyMS4zNCIgZmlsbD0iI2ZlZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDQ3NzMiIGQ9Im0zNDUuNTggOTQ1LjY0Yy00My43NTIgMC03OS4yMi05LjUxNjEtNzkuMjItMjEuMjU1IDAtNS45NDE0IDkuMDg1LTExLjMxMiAyMy43My0xNS4xNjkgMC4wMzg4IDAuMDMxMiAwLjA3MzggMC4wNTkxIDAuMTEyNSAwLjA5MDQtMTQuNTIxIDMuODM5OS0yMy41MjIgOS4xNzcyLTIzLjUyMiAxNS4wNzkgMCAxMS42OTEgMzUuMzI1IDIxLjE2OSA3OC45IDIxLjE2OSA0My41NzQgMCA3OC44OTktOS40Nzc2IDc4Ljg5OS0yMS4xNjkgMC01LjkwMTQtOS4wMDEyLTExLjIzOS0yMy41MjItMTUuMDc5IDAuMDM5LTAuMDMxMiAwLjA3NC0wLjA1OTEgMC4xMTI1LTAuMDkwNCAxNC42NDYgMy44NTY1IDIzLjczIDkuMjI3NiAyMy43MyAxNS4xNjkgMCAxMS43MzktMzUuNDY4IDIxLjI1NS03OS4yMTkgMjEuMjU1IiBmaWxsPSIjZmRmZWZlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc3NSIgZD0ibTM0NS41OCA5NDUuNTVjLTQzLjU3NSAwLTc4LjktOS40Nzc2LTc4LjktMjEuMTY5IDAtNS45MDE0IDkuMDAxMi0xMS4yMzkgMjMuNTIyLTE1LjA3OSAwLjAzNzUgMC4wMjk3IDAuMDc3NSAwLjA2MjUgMC4xMTM3NSAwLjA5MTQtMTQuMzk1IDMuODIwOC0yMy4zMTUgOS4xMjUtMjMuMzE1IDE0Ljk4NyAwIDExLjY0NCAzNS4xODEgMjEuMDgzIDc4LjU3OSAyMS4wODNzNzguNTc4LTkuNDM5IDc4LjU3OC0yMS4wODNjMC01Ljg2MjItOC45Mi0xMS4xNjYtMjMuMzE1LTE0Ljk4NyAwLjAzOC0wLjAyODkgMC4wNzYtMC4wNjE2IDAuMTEzNy0wLjA5MTQgMTQuNTIxIDMuODM5OSAyMy41MjIgOS4xNzcyIDIzLjUyMiAxNS4wNzkgMCAxMS42OTEtMzUuMzI1IDIxLjE2OS03OC44OTkgMjEuMTY5IiBmaWxsPSIjZmRmZWZlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc3NyIgZD0ibTM0NS41OCA5NDUuNDdjLTQzLjM5OCAwLTc4LjU3OS05LjQzOS03OC41NzktMjEuMDgzIDAtNS44NjIyIDguOTItMTEuMTY2IDIzLjMxNS0xNC45ODcgMC4wMzg3IDAuMDMxMiAwLjA3NSAwLjA2IDAuMTEzNzUgMC4wOTA3LTE0LjI3MSAzLjgwNDItMjMuMTA4IDkuMDcyOC0yMy4xMDggMTQuODk2IDAgMTEuNTk2IDM1LjAzNiAyMC45OTYgNzguMjU4IDIwLjk5NiA0My4yMiAwIDc4LjI1OC05LjM5OTkgNzguMjU4LTIwLjk5NiAwLTUuODIzOC04LjgzNjMtMTEuMDkyLTIzLjEwOS0xNC44OTYgMC4wMzktMC4wMzA4IDAuMDc1LTAuMDU5NSAwLjExMzgtMC4wOTA3IDE0LjM5NSAzLjgyMDggMjMuMzE1IDkuMTI1IDIzLjMxNSAxNC45ODcgMCAxMS42NDQtMzUuMTggMjEuMDgzLTc4LjU3OCAyMS4wODMiIGZpbGw9IiNmZGZkZmUiLz4KICAgPHBhdGggaWQ9InBhdGg0Nzc5IiBkPSJtMzQ1LjU4IDk0NS4zOGMtNDMuMjIxIDAtNzguMjU4LTkuMzk5OS03OC4yNTgtMjAuOTk2IDAtNS44MjM4IDguODM2Mi0xMS4wOTIgMjMuMTA4LTE0Ljg5NiAwLjAzNjIgMC4wMjg5IDAuMDc3NSAwLjA2MTUgMC4xMTM3NSAwLjA5MTQtMTQuMTQ2IDMuNzg1MS0yMi45MDEgOS4wMjE0LTIyLjkwMSAxNC44MDUgMCAxMS41NDkgMzQuODk0IDIwLjkxIDc3LjkzOCAyMC45MSA0My4wNDMgMCA3Ny45MzYtOS4zNjEzIDc3LjkzNi0yMC45MSAwLTUuNzgzOC04Ljc1NS0xMS4wMi0yMi45MDEtMTQuODA1IDAuMDM2LTAuMDI5OSAwLjA3OC0wLjA2MjUgMC4xMTM3LTAuMDkxNCAxNC4yNzIgMy44MDQyIDIzLjEwOSA5LjA3MjggMjMuMTA5IDE0Ljg5NiAwIDExLjU5Ni0zNS4wMzggMjAuOTk2LTc4LjI1OCAyMC45OTYiIGZpbGw9IiNmZGZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg0NzgxIiBkPSJtMzQ1LjU4IDk0NS4zYy00My4wNDQgMC03Ny45MzgtOS4zNjEzLTc3LjkzOC0yMC45MSAwLTUuNzgzOCA4Ljc1NS0xMS4wMiAyMi45MDEtMTQuODA1IDAuMDM4NyAwLjAzMTIgMC4wNzYyIDAuMDYgMC4xMTUgMC4wOTAyLTE0LjAyNSAzLjc2ODYtMjIuNjk1IDguOTY5Ny0yMi42OTUgMTQuNzE1IDAgMTEuNTAxIDM0Ljc1IDIwLjgyNCA3Ny42MTYgMjAuODI0IDQyLjg2NSAwIDc3LjYxNS05LjMyMjMgNzcuNjE1LTIwLjgyNCAwLTUuNzQ1MS04LjY3LTEwLjk0Ni0yMi42OTUtMTQuNzE1IDAuMDQtMC4wMzAzIDAuMDc2LTAuMDU5IDAuMTE1LTAuMDkwMiAxNC4xNDYgMy43ODUxIDIyLjkwMSA5LjAyMTQgMjIuOTAxIDE0LjgwNSAwIDExLjU0OS0zNC44OTQgMjAuOTEtNzcuOTM2IDIwLjkxIiBmaWxsPSIjZmRmZGZkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc4MyIgZD0ibTM0NS41OCA5NDUuMjFjLTQyLjg2NiAwLTc3LjYxNi05LjMyMjMtNzcuNjE2LTIwLjgyNCAwLTUuNzQ1MSA4LjY3LTEwLjk0NiAyMi42OTUtMTQuNzE1IDAuMDM2MiAwLjAyOTkgMC4wNzc1IDAuMDYyNSAwLjExNSAwLjA5MTQtMTMuOTAxIDMuNzUtMjIuNDkgOC45MTc1LTIyLjQ5IDE0LjYyNCAwIDExLjQ1NCAzNC42MDggMjAuNzM3IDc3LjI5NiAyMC43MzcgNDIuNjg5IDAgNzcuMjk1LTkuMjgzNyA3Ny4yOTUtMjAuNzM3IDAtNS43MDYtOC41ODg4LTEwLjg3NC0yMi40OS0xNC42MjQgMC4wMzgtMC4wMjg5IDAuMDc5LTAuMDYxNSAwLjExNS0wLjA5MTQgMTQuMDI1IDMuNzY4NiAyMi42OTUgOC45Njk3IDIyLjY5NSAxNC43MTUgMCAxMS41MDEtMzQuNzUgMjAuODI0LTc3LjYxNSAyMC44MjQiIGZpbGw9IiNmY2ZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg0Nzg1IiBkPSJtMzQ1LjU4IDk0NS4xMmMtNDIuNjg5IDAtNzcuMjk2LTkuMjgzNy03Ny4yOTYtMjAuNzM3IDAtNS43MDYgOC41ODg4LTEwLjg3NCAyMi40OS0xNC42MjQgMC4wMzg3IDAuMDMxMiAwLjA3NjMgMC4wNTk1IDAuMTE1IDAuMDkwNy0xMy43NzggMy43MzE1LTIyLjI4NCA4Ljg2NTItMjIuMjg0IDE0LjUzMyAwIDExLjQwNiAzNC40NjIgMjAuNjUyIDc2Ljk3NSAyMC42NTIgNDIuNTExIDAgNzYuOTc0LTkuMjQ2MSA3Ni45NzQtMjAuNjUyIDAtNS42Njc1LTguNTA2Mi0xMC44MDEtMjIuMjg0LTE0LjUzMyAwLjA0LTAuMDMxMiAwLjA3Ni0wLjA1OTUgMC4xMTUtMC4wOTA3IDEzLjkwMSAzLjc1IDIyLjQ5IDguOTE3NSAyMi40OSAxNC42MjQgMCAxMS40NTQtMzQuNjA2IDIwLjczNy03Ny4yOTUgMjAuNzM3IiBmaWxsPSIjZmNmZGZkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc4NyIgZD0ibTM0NS41OCA5NDUuMDRjLTQyLjUxMiAwLTc2Ljk3NS05LjI0NjEtNzYuOTc1LTIwLjY1MiAwLTUuNjY3NSA4LjUwNjItMTAuODAxIDIyLjI4NC0xNC41MzMgMC4wMzg3IDAuMDMxMiAwLjA3NjIgMC4wNjAxIDAuMTE2MjUgMC4wOTE0LTEzLjY1MiAzLjcxMzktMjIuMDc5IDguODEzOS0yMi4wNzkgMTQuNDQxIDAgMTEuMzU5IDM0LjMxOSAyMC41NjYgNzYuNjU0IDIwLjU2NiA0Mi4zMzQgMCA3Ni42NTQtOS4yMDc1IDc2LjY1NC0yMC41NjYgMC01LjYyNzUtOC40Mjc1LTEwLjcyOC0yMi4wNzktMTQuNDQxIDAuMDM5LTAuMDMxMiAwLjA3Ni0wLjA2MDEgMC4xMTUtMC4wOTE0IDEzLjc3OCAzLjczMTUgMjIuMjg0IDguODY1MiAyMi4yODQgMTQuNTMzIDAgMTEuNDA2LTM0LjQ2MiAyMC42NTItNzYuOTc0IDIwLjY1MiIgZmlsbD0iI2ZjZmNmZCIvPgogICA8cGF0aCBpZD0icGF0aDQ3ODkiIGQ9Im0zNDUuNTggOTQ0Ljk1Yy00Mi4zMzUgMC03Ni42NTQtOS4yMDc1LTc2LjY1NC0yMC41NjYgMC01LjYyNzUgOC40MjYyLTEwLjcyOCAyMi4wNzktMTQuNDQxIDAuMDM2MiAwLjAyODcgMC4wNzg3IDAuMDYyNSAwLjExNSAwLjA5MTItMTMuNTI5IDMuNjk1NC0yMS44NzQgOC43NjEyLTIxLjg3NCAxNC4zNSAwIDExLjMxMSAzNC4xNzYgMjAuNDggNzYuMzM0IDIwLjQ4czc2LjMzMy05LjE2ODkgNzYuMzMzLTIwLjQ4YzAtNS41ODg5LTguMzQ1LTEwLjY1NS0yMS44NzQtMTQuMzUgMC4wMzYtMC4wMjg3IDAuMDc5LTAuMDYyNSAwLjExNjMtMC4wOTEyIDEzLjY1MSAzLjcxMzkgMjIuMDc5IDguODEzOSAyMi4wNzkgMTQuNDQxIDAgMTEuMzU5LTM0LjMyIDIwLjU2Ni03Ni42NTQgMjAuNTY2IiBmaWxsPSIjZmNmY2ZjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc5MSIgZD0ibTM0NS41OCA5NDQuODdjLTQyLjE1OCAwLTc2LjMzNC05LjE2ODktNzYuMzM0LTIwLjQ4IDAtNS41ODg5IDguMzQ1LTEwLjY1NSAyMS44NzQtMTQuMzUgMC4wNCAwLjAzMDMgMC4wNzc1IDAuMDYwMSAwLjExNzUgMC4wOTE0LTEzLjQwNSAzLjY3NjItMjEuNjcgOC43MDktMjEuNjcgMTQuMjU5IDAgMTEuMjY0IDM0LjAzMSAyMC4zOTQgNzYuMDEyIDIwLjM5NCA0MS45OCAwIDc2LjAxMS05LjEyOTkgNzYuMDExLTIwLjM5NCAwLTUuNTQ5OC04LjI2NS0xMC41ODItMjEuNjY5LTE0LjI1OSAwLjAzOS0wLjAzMTIgMC4wNzYtMC4wNjExIDAuMTE2Mi0wLjA5MTQgMTMuNTI5IDMuNjk1NCAyMS44NzQgOC43NjEyIDIxLjg3NCAxNC4zNSAwIDExLjMxMS0zNC4xNzUgMjAuNDgtNzYuMzMzIDIwLjQ4IiBmaWxsPSIjZmJmY2ZjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc5MyIgZD0ibTM0NS41OCA5NDQuNzhjLTQxLjk4MSAwLTc2LjAxMi05LjEyOTktNzYuMDEyLTIwLjM5NCAwLTUuNTQ5OCA4LjI2NS0xMC41ODIgMjEuNjctMTQuMjU5IDAuMDM4NyAwLjAyOTggMC4wNzc1IDAuMDYgMC4xMTYyNSAwLjA4OTgtMTMuMjg0IDMuNjYwMi0yMS40NjUgOC42NTc4LTIxLjQ2NSAxNC4xNjkgMCAxMS4yMTYgMzMuODg4IDIwLjMwOCA3NS42OTEgMjAuMzA4czc1LjY5MS05LjA5MTQgNzUuNjkxLTIwLjMwOGMwLTUuNTExMi04LjE4MjUtMTAuNTA5LTIxLjQ2Ni0xNC4xNjkgMC4wMzktMC4wMjk4IDAuMDc4LTAuMDYgMC4xMTc1LTAuMDg5OCAxMy40MDQgMy42NzYyIDIxLjY2OSA4LjcwOSAyMS42NjkgMTQuMjU5IDAgMTEuMjY0LTM0LjAzMSAyMC4zOTQtNzYuMDExIDIwLjM5NCIgZmlsbD0iI2ZiZmNmYyIvPgogICA8cGF0aCBpZD0icGF0aDQ3OTUiIGQ9Im0zNDUuNTggOTQ0LjY5Yy00MS44MDQgMC03NS42OTEtOS4wOTE0LTc1LjY5MS0yMC4zMDggMC01LjUxMTIgOC4xODEyLTEwLjUwOSAyMS40NjUtMTQuMTY5IDAuMDQgMC4wMzEyIDAuMDc4NyAwLjA2MTYgMC4xMTc1IDAuMDkxNC0xMy4xNjEgMy42NDExLTIxLjI2MiA4LjYwNS0yMS4yNjIgMTQuMDc4IDAgMTEuMTY5IDMzLjc0NSAyMC4yMjEgNzUuMzcxIDIwLjIyMXM3NS4zNy05LjA1MjIgNzUuMzctMjAuMjIxYzAtNS40NzI2LTguMTAxMy0xMC40MzYtMjEuMjYyLTE0LjA3OCAwLjA0LTAuMDI5OCAwLjA3OC0wLjA2MDEgMC4xMTc1LTAuMDkxNCAxMy4yODQgMy42NjAyIDIxLjQ2NiA4LjY1NzggMjEuNDY2IDE0LjE2OSAwIDExLjIxNi0zMy44ODkgMjAuMzA4LTc1LjY5MSAyMC4zMDgiIGZpbGw9IiNmYmZiZmIiLz4KICAgPHBhdGggaWQ9InBhdGg0Nzk3IiBkPSJtMzQ1LjU4IDk0NC42MWMtNDEuNjI2IDAtNzUuMzcxLTkuMDUyMi03NS4zNzEtMjAuMjIxIDAtNS40NzI2IDguMTAxMi0xMC40MzYgMjEuMjYyLTE0LjA3OCAwLjA0IDAuMDMxMiAwLjA3ODcgMC4wNjE1IDAuMTE3NSAwLjA5MTMtMTMuMDM5IDMuNjIyNi0yMS4wNTkgOC41NTI4LTIxLjA1OSAxMy45ODYgMCAxMS4xMiAzMy42IDIwLjEzNiA3NS4wNSAyMC4xMzYgNDEuNDQ5IDAgNzUuMDQ5LTkuMDE2MSA3NS4wNDktMjAuMTM2IDAtNS40MzM2LTguMDItMTAuMzY0LTIxLjA1OS0xMy45ODYgMC4wMzktMC4wMjk3IDAuMDc5LTAuMDYgMC4xMTc1LTAuMDkxMyAxMy4xNjEgMy42NDExIDIxLjI2MiA4LjYwNSAyMS4yNjIgMTQuMDc4IDAgMTEuMTY5LTMzLjc0NCAyMC4yMjEtNzUuMzcgMjAuMjIxIiBmaWxsPSIjZmJmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDc5OSIgZD0ibTM0NS41OCA5NDQuNTJjLTQxLjQ1IDAtNzUuMDUtOS4wMTYxLTc1LjA1LTIwLjEzNiAwLTUuNDMzNiA4LjAyLTEwLjM2NCAyMS4wNTktMTMuOTg2IDAuMDQgMC4wMzA0IDAuMDc4OCAwLjA2MTYgMC4xMTg3NSAwLjA5MTQtMTIuOTE4IDMuNjA0LTIwLjg1OCA4LjUtMjAuODU4IDEzLjg5NSAwIDExLjA3MiAzMy40NTggMjAuMDUgNzQuNzMgMjAuMDUgNDEuMjcxIDAgNzQuNzI5LTguOTc3NiA3NC43MjktMjAuMDUgMC01LjM5NS03Ljk0LTEwLjI5MS0yMC44NTgtMTMuODk1IDAuMDQtMC4wMjk4IDAuMDc5LTAuMDYxIDAuMTE4OC0wLjA5MTQgMTMuMDM5IDMuNjIyNiAyMS4wNTkgOC41NTI4IDIxLjA1OSAxMy45ODYgMCAxMS4xMi0zMy42IDIwLjEzNi03NS4wNDkgMjAuMTM2IiBmaWxsPSIjZmFmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDgwMSIgZD0ibTM0NS41OCA5NDQuNDRjLTQxLjI3MiAwLTc0LjczLTguOTc3Ni03NC43My0yMC4wNSAwLTUuMzk1IDcuOTQtMTAuMjkxIDIwLjg1OC0xMy44OTUgMC4wNCAwLjAyOTcgMC4wNzg4IDAuMDYxNSAwLjExODc1IDAuMDkxMi0xMi43OTQgMy41ODUtMjAuNjU1IDguNDQ4OC0yMC42NTUgMTMuODA0IDAgMTEuMDI1IDMzLjMxNCAxOS45NjQgNzQuNDA5IDE5Ljk2NCA0MS4wOTQgMCA3NC40MDgtOC45MzkgNzQuNDA4LTE5Ljk2NCAwLTUuMzU2NS03Ljg1ODgtMTAuMjE5LTIwLjY1NS0xMy44MDQgMC4wNC0wLjAyOTggMC4wOC0wLjA2MTUgMC4xMTg3LTAuMDkxMiAxMi45MTggMy42MDQgMjAuODU4IDguNSAyMC44NTggMTMuODk1IDAgMTEuMDcyLTMzLjQ1OCAyMC4wNS03NC43MjkgMjAuMDUiIGZpbGw9IiNmYWZiZmIiLz4KICAgPHBhdGggaWQ9InBhdGg0ODAzIiBkPSJtMzQ1LjU4IDk0NC4zNWMtNDEuMDk1IDAtNzQuNDA5LTguOTM5LTc0LjQwOS0xOS45NjQgMC01LjM1NSA3Ljg2MTItMTAuMjE5IDIwLjY1NS0xMy44MDQgMC4wNCAwLjAyOTkgMC4wNzg3IDAuMDYxMSAwLjExODc1IDAuMDkxNC0xMi42NzIgMy41NjU5LTIwLjQ1MiA4LjM5Ni0yMC40NTIgMTMuNzEyIDAgMTAuOTc4IDMzLjE3IDE5Ljg3NyA3NC4wODggMTkuODc3czc0LjA4OC04Ljg5OTkgNzQuMDg4LTE5Ljg3N2MwLTUuMzE2NC03Ljc4MTMtMTAuMTQ2LTIwLjQ1NC0xMy43MTIgMC4wNC0wLjAzMDIgMC4wOC0wLjA2MTUgMC4xMTg4LTAuMDkxNCAxMi43OTYgMy41ODUgMjAuNjU1IDguNDQ3MiAyMC42NTUgMTMuODA0IDAgMTEuMDI1LTMzLjMxNCAxOS45NjQtNzQuNDA4IDE5Ljk2NCIgZmlsbD0iI2ZhZmFmYSIvPgogICA8cGF0aCBpZD0icGF0aDQ4MDUiIGQ9Im0zNDUuNTggOTQ0LjI2Yy00MC45MTggMC03NC4wODgtOC44OTk5LTc0LjA4OC0xOS44NzcgMC01LjMxNjQgNy43OC0xMC4xNDYgMjAuNDUyLTEzLjcxMiAwLjA0IDAuMDI5NyAwLjA4IDAuMDYxIDAuMTIgMC4wOTEzLTEyLjU1IDMuNTQ3NC0yMC4yNTIgOC4zNDM4LTIwLjI1MiAxMy42MjEgMCAxMC45MyAzMy4wMjYgMTkuNzkxIDczLjc2OCAxOS43OTEgNDAuNzQgMCA3My43NjYtOC44NjA5IDczLjc2Ni0xOS43OTEgMC01LjI3NzQtNy43MDEyLTEwLjA3NC0yMC4yNTItMTMuNjIxIDAuMDQtMC4wMzAzIDAuMDgtMC4wNjE1IDAuMTItMC4wOTEzIDEyLjY3MiAzLjU2NTkgMjAuNDU0IDguMzk2IDIwLjQ1NCAxMy43MTIgMCAxMC45NzgtMzMuMTcgMTkuODc3LTc0LjA4OCAxOS44NzciIGZpbGw9IiNmYWZhZmEiLz4KICAgPHBhdGggaWQ9InBhdGg0ODA3IiBkPSJtMzQ1LjU4IDk0NC4xOGMtNDAuNzQxIDAtNzMuNzY4LTguODYwOS03My43NjgtMTkuNzkxIDAtNS4yNzc0IDcuNzAyNS0xMC4wNzQgMjAuMjUyLTEzLjYyMSAwLjA0IDAuMDI5OSAwLjA4IDAuMDYxMSAwLjEyIDAuMDkxNC0xMi40MzEgMy41MjgyLTIwLjA1MSA4LjI5MS0yMC4wNTEgMTMuNTMgMCAxMC44ODIgMzIuODgyIDE5LjcwNSA3My40NDYgMTkuNzA1IDQwLjU2MyAwIDczLjQ0NS04LjgyMjcgNzMuNDQ1LTE5LjcwNSAwLTUuMjM4OC03LjYxODgtMTAuMDAyLTIwLjA1MS0xMy41MyAwLjA0LTAuMDMwMyAwLjA4MS0wLjA2MTUgMC4xMi0wLjA5MTQgMTIuNTUxIDMuNTQ3NCAyMC4yNTIgOC4zNDM4IDIwLjI1MiAxMy42MjEgMCAxMC45My0zMy4wMjYgMTkuNzkxLTczLjc2NiAxOS43OTEiIGZpbGw9IiNmOWZhZmEiLz4KICAgPHBhdGggaWQ9InBhdGg0ODA5IiBkPSJtMzQ1LjU4IDk0NC4wOWMtNDAuNTY0IDAtNzMuNDQ2LTguODIyNy03My40NDYtMTkuNzA1IDAtNS4yMzg4IDcuNjItMTAuMDAyIDIwLjA1MS0xMy41MyAwLjA0IDAuMDI5OCAwLjA4IDAuMDYxIDAuMTIgMC4wOTA3LTEyLjMxIDMuNTEwMi0xOS44NTEgOC4yMzg4LTE5Ljg1MSAxMy40MzkgMCAxMC44MzUgMzIuNzQgMTkuNjE5IDczLjEyNiAxOS42MTkgNDAuMzg1IDAgNzMuMTI1LTguNzgzNiA3My4xMjUtMTkuNjE5IDAtNS4yMDAyLTcuNTQxMy05LjkyODgtMTkuODUxLTEzLjQzOSAwLjA0LTAuMDI5NyAwLjA4MS0wLjA2MSAwLjEyLTAuMDkwNyAxMi40MzIgMy41MjgyIDIwLjA1MSA4LjI5MSAyMC4wNTEgMTMuNTMgMCAxMC44ODItMzIuODgyIDE5LjcwNS03My40NDUgMTkuNzA1IiBmaWxsPSIjZjlmOWY5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDgxMSIgZD0ibTM0NS41OCA5NDRjLTQwLjM4NiAwLTczLjEyNi04Ljc4MzYtNzMuMTI2LTE5LjYxOSAwLTUuMjAwMiA3LjU0MTItOS45Mjg4IDE5Ljg1MS0xMy40MzkgMC4wNCAwLjAzMDMgMC4wODEzIDAuMDYxNSAwLjEyMTI1IDAuMDkxNC0xMi4xODggMy40OTExLTE5LjY1MSA4LjE4NjUtMTkuNjUxIDEzLjM0OCAwIDEwLjc4OCAzMi41OTYgMTkuNTM0IDcyLjgwNSAxOS41MzRzNzIuODA0LTguNzQ2MSA3Mi44MDQtMTkuNTM0YzAtNS4xNjExLTcuNDYzNy05Ljg1NjUtMTkuNjUxLTEzLjM0OCAwLjA0LTAuMDMxMiAwLjA4MS0wLjA2MTEgMC4xMjEyLTAuMDkxNCAxMi4zMSAzLjUxMDIgMTkuODUxIDguMjM4OCAxOS44NTEgMTMuNDM5IDAgMTAuODM1LTMyLjc0IDE5LjYxOS03My4xMjUgMTkuNjE5IiBmaWxsPSIjZjlmOWY5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDgxMyIgZD0ibTM0NS41OCA5NDMuOTJjLTQwLjIwOSAwLTcyLjgwNS04Ljc0NjEtNzIuODA1LTE5LjUzNCAwLTUuMTYxMSA3LjQ2MzgtOS44NTY1IDE5LjY1MS0xMy4zNDggMC4wNDEyIDAuMDMxMiAwLjA3ODggMC4wNTkgMC4xMjEyNSAwLjA5MTItMTIuMDY4IDMuNDcyNi0xOS40NTEgOC4xMzM5LTE5LjQ1MSAxMy4yNTYgMCAxMC43NCAzMi40NTEgMTkuNDQ4IDcyLjQ4NCAxOS40NDggNDAuMDMxIDAgNzIuNDg0LTguNzA3NSA3Mi40ODQtMTkuNDQ4IDAtNS4xMjI1LTcuMzg1LTkuNzgzOC0xOS40NTItMTMuMjU2IDAuMDQzLTAuMDMyMiAwLjA4LTAuMDYgMC4xMjEzLTAuMDkxMiAxMi4xODggMy40OTExIDE5LjY1MSA4LjE4NjUgMTkuNjUxIDEzLjM0OCAwIDEwLjc4OC0zMi41OTUgMTkuNTM0LTcyLjgwNCAxOS41MzQiIGZpbGw9IiNmOGY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg0ODE1IiBkPSJtMzQ1LjU4IDk0My44M2MtNDAuMDMyIDAtNzIuNDg0LTguNzA3NS03Mi40ODQtMTkuNDQ4IDAtNS4xMjI1IDcuMzgzOC05Ljc4MzggMTkuNDUxLTEzLjI1NiAwLjA0IDAuMDMwMyAwLjA4MTMgMC4wNjE1IDAuMTIxMjUgMC4wOTE0LTExLjk0OSAzLjQ1MzYtMTkuMjUyIDguMDgtMTkuMjUyIDEzLjE2NSAwIDEwLjY5MiAzMi4zMDkgMTkuMzYxIDcyLjE2NCAxOS4zNjEgMzkuODU0IDAgNzIuMTYzLTguNjY5IDcyLjE2My0xOS4zNjEgMC01LjA4NS03LjMwMzgtOS43MTE0LTE5LjI1Mi0xMy4xNjUgMC4wNC0wLjAyOTkgMC4wODEtMC4wNjExIDAuMTIxMi0wLjA5MTQgMTIuMDY4IDMuNDcyNiAxOS40NTIgOC4xMzM5IDE5LjQ1MiAxMy4yNTYgMCAxMC43NC0zMi40NTIgMTkuNDQ4LTcyLjQ4NCAxOS40NDgiIGZpbGw9IiNmOGY4ZjgiLz4KICAgPHBhdGggaWQ9InBhdGg0ODE3IiBkPSJtMzQ1LjU4IDk0My43NWMtMzkuODU1IDAtNzIuMTY0LTguNjY5LTcyLjE2NC0xOS4zNjEgMC01LjA4NSA3LjMwMzgtOS43MTE0IDE5LjI1Mi0xMy4xNjUgMC4wNCAwLjAyODggMC4wODI1IDAuMDYxNSAwLjEyMjUgMC4wOTEzLTExLjgyOCAzLjQzNTEtMTkuMDU0IDguMDI3NC0xOS4wNTQgMTMuMDc0IDAgMTAuNjQ1IDMyLjE2NSAxOS4yNzUgNzEuODQyIDE5LjI3NSAzOS42NzYgMCA3MS44NDEtOC42Mjk5IDcxLjg0MS0xOS4yNzUgMC01LjA0NjQtNy4yMjUtOS42Mzg2LTE5LjA1NC0xMy4wNzQgMC4wNC0wLjAyOTggMC4wODItMC4wNjI1IDAuMTIyNS0wLjA5MTMgMTEuOTQ5IDMuNDUzNiAxOS4yNTIgOC4wOCAxOS4yNTIgMTMuMTY1IDAgMTAuNjkyLTMyLjMwOSAxOS4zNjEtNzIuMTYzIDE5LjM2MSIgZmlsbD0iI2Y4ZjhmOCIvPgogICA8cGF0aCBpZD0icGF0aDQ4MTkiIGQ9Im0zNDUuNTggOTQzLjY2Yy0zOS42NzggMC03MS44NDItOC42Mjk5LTcxLjg0Mi0xOS4yNzUgMC01LjA0NjQgNy4yMjYyLTkuNjM4NiAxOS4wNTQtMTMuMDc0IDAuMDQyNSAwLjAzMTIgMC4wOCAwLjA1ODYgMC4xMjI1IDAuMDkxNC0xMS43MDggMy40MTYtMTguODU2IDcuOTc1LTE4Ljg1NiAxMi45ODIgMCAxMC41OTggMzIuMDIxIDE5LjE4OSA3MS41MjIgMTkuMTg5IDM5LjUgMCA3MS41MjEtOC41OTEzIDcxLjUyMS0xOS4xODkgMC01LjAwNzQtNy4xNDg3LTkuNTY2NC0xOC44NTYtMTIuOTgyIDAuMDQzLTAuMDMyNyAwLjA4LTAuMDYwMSAwLjEyMjUtMC4wOTE0IDExLjgyOSAzLjQzNTEgMTkuMDU0IDguMDI3NCAxOS4wNTQgMTMuMDc0IDAgMTAuNjQ1LTMyLjE2NSAxOS4yNzUtNzEuODQxIDE5LjI3NSIgZmlsbD0iI2Y3ZjhmOCIvPgogICA8cGF0aCBpZD0icGF0aDQ4MjEiIGQ9Im0zNDUuNTggOTQzLjU3Yy0zOS41MDEgMC03MS41MjItOC41OTEzLTcxLjUyMi0xOS4xODkgMC01LjAwNzQgNy4xNDg4LTkuNTY2NCAxOC44NTYtMTIuOTgyIDAuMDQgMC4wMjg4IDAuMDgzNyAwLjA2MSAwLjEyMzc1IDAuMDkxMy0xMS41ODkgMy4zOTc1LTE4LjY1OSA3LjkyMjQtMTguNjU5IDEyLjg5MSAwIDEwLjU1IDMxLjg3OCAxOS4xMDMgNzEuMjAxIDE5LjEwM3M3MS4yLTguNTUyOCA3MS4yLTE5LjEwM2MwLTQuOTY4OC03LjA3LTkuNDkzNi0xOC42NTktMTIuODkxIDAuMDQtMC4wMzAzIDAuMDg0LTAuMDYyNSAwLjEyMzgtMC4wOTEzIDExLjcwOCAzLjQxNiAxOC44NTYgNy45NzUgMTguODU2IDEyLjk4MiAwIDEwLjU5OC0zMi4wMjEgMTkuMTg5LTcxLjUyMSAxOS4xODkiIGZpbGw9IiNmN2Y3ZjciLz4KICAgPHBhdGggaWQ9InBhdGg0ODIzIiBkPSJtMzQ1LjU4IDk0My40OWMtMzkuMzI0IDAtNzEuMjAxLTguNTUyOC03MS4yMDEtMTkuMTAzIDAtNC45Njg4IDcuMDctOS40OTM2IDE4LjY1OS0xMi44OTEgMC4wNDI1IDAuMDMxMiAwLjA4MTIgMC4wNjAxIDAuMTIzNzUgMC4wOTE0LTExLjQ3IDMuMzc4NC0xOC40NjEgNy44Njk2LTE4LjQ2MSAxMi44IDAgMTAuNTAyIDMxLjczNCAxOS4wMTYgNzAuODggMTkuMDE2IDM5LjE0NSAwIDcwLjg4LTguNTEzNiA3MC44OC0xOS4wMTYgMC00LjkzMDEtNi45OTI1LTkuNDIxNC0xOC40NjEtMTIuOCAwLjA0MS0wLjAzMTIgMC4wOC0wLjA2MDEgMC4xMjI1LTAuMDkxNCAxMS41ODkgMy4zOTc1IDE4LjY1OSA3LjkyMjQgMTguNjU5IDEyLjg5MSAwIDEwLjU1LTMxLjg3OCAxOS4xMDMtNzEuMiAxOS4xMDMiIGZpbGw9IiNmNmY3ZjciLz4KICAgPHBhdGggaWQ9InBhdGg0ODI1IiBkPSJtMzQ1LjU4IDk0My40Yy0zOS4xNDYgMC03MC44OC04LjUxMzYtNzAuODgtMTkuMDE2IDAtNC45MzAxIDYuOTkxMi05LjQyMTQgMTguNDYxLTEyLjggMC4wNCAwLjAyODIgMC4wODM3IDAuMDYxIDAuMTIzNzUgMC4wOTA3LTExLjM0OSAzLjM1ODktMTguMjY1IDcuODE3OS0xOC4yNjUgMTIuNzA5IDAgMTAuNDU1IDMxLjU5MSAxOC45MzEgNzAuNTYgMTguOTMxczcwLjU1OS04LjQ3NiA3MC41NTktMTguOTMxYzAtNC44OTExLTYuOTE1LTkuMzUwMS0xOC4yNjUtMTIuNzA5IDAuMDQtMC4wMjk3IDAuMDg0LTAuMDYyNSAwLjEyNS0wLjA5MDcgMTEuNDY5IDMuMzc4NCAxOC40NjEgNy44Njk2IDE4LjQ2MSAxMi44IDAgMTAuNTAyLTMxLjczNSAxOS4wMTYtNzAuODggMTkuMDE2IiBmaWxsPSIjZjZmNmY3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDgyNyIgZD0ibTM0NS41OCA5NDMuMzJjLTM4Ljk2OSAwLTcwLjU2LTguNDc2LTcwLjU2LTE4LjkzMSAwLTQuODkxMSA2LjkxNjItOS4zNTAxIDE4LjI2NS0xMi43MDkgMC4wNDI1IDAuMDMxMiAwLjA4MjUgMC4wNjAxIDAuMTI1IDAuMDkxNC0xMS4yMzIgMy4zNDAyLTE4LjA2OSA3Ljc2NTEtMTguMDY5IDEyLjYxOCAwIDEwLjQwOCAzMS40NDYgMTguODQ1IDcwLjIzOSAxOC44NDUgMzguNzkxIDAgNzAuMjM4LTguNDM3NSA3MC4yMzgtMTguODQ1IDAtNC44NTI1LTYuODM2My05LjI3NzQtMTguMDY4LTEyLjYxOCAwLjA0My0wLjAzMTIgMC4wODEtMC4wNjAxIDAuMTIzNy0wLjA5MTQgMTEuMzUgMy4zNTg5IDE4LjI2NSA3LjgxNzkgMTguMjY1IDEyLjcwOSAwIDEwLjQ1NS0zMS41OSAxOC45MzEtNzAuNTU5IDE4LjkzMSIgZmlsbD0iI2Y2ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDQ4MjkiIGQ9Im0zNDUuNTggOTQzLjIzYy0zOC43OTIgMC03MC4yMzktOC40Mzc1LTcwLjIzOS0xOC44NDUgMC00Ljg1MjUgNi44MzYyLTkuMjc3NCAxOC4wNjktMTIuNjE4IDAuMDQgMC4wMjg3IDAuMDgzNyAwLjA2MTUgMC4xMjUgMC4wOTEyLTExLjExNCAzLjMyMTMtMTcuODcyIDcuNzExNC0xNy44NzIgMTIuNTI2IDAgMTAuMzYgMzEuMzAyIDE4Ljc1OSA2OS45MTggMTguNzU5IDM4LjYxNCAwIDY5LjkxOC04LjM5ODkgNjkuOTE4LTE4Ljc1OSAwLTQuODE1LTYuNzYtOS4yMDUxLTE3Ljg3NC0xMi41MjYgMC4wNDEtMC4wMjk3IDAuMDg1LTAuMDYyNSAwLjEyNjMtMC4wOTEyIDExLjIzMSAzLjM0MDIgMTguMDY4IDcuNzY1MSAxOC4wNjggMTIuNjE4IDAgMTAuNDA4LTMxLjQ0NiAxOC44NDUtNzAuMjM4IDE4Ljg0NSIgZmlsbD0iI2Y1ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDQ4MzEiIGQ9Im0zNDUuNTggOTQzLjE0Yy0zOC42MTUgMC02OS45MTgtOC4zOTg5LTY5LjkxOC0xOC43NTkgMC00LjgxNSA2Ljc1ODgtOS4yMDUxIDE3Ljg3Mi0xMi41MjYgMC4wNDI1IDAuMDMxMiAwLjA4MjUgMC4wNjAxIDAuMTI1IDAuMDkxNC0xMC45OTUgMy4zMDI2LTE3LjY3OCA3LjY1ODYtMTcuNjc4IDEyLjQzNSAwIDEwLjMxMiAzMS4xNiAxOC42NzIgNjkuNTk4IDE4LjY3MiAzOC40MzYgMCA2OS41OTYtOC4zNTk5IDY5LjU5Ni0xOC42NzIgMC00Ljc3NjQtNi42ODI1LTkuMTMyNC0xNy42NzgtMTIuNDM1IDAuMDQzLTAuMDMxMiAwLjA4Mi0wLjA2MDEgMC4xMjUtMC4wOTE0IDExLjExNCAzLjMyMTMgMTcuODc0IDcuNzExNCAxNy44NzQgMTIuNTI2IDAgMTAuMzYtMzEuMzA0IDE4Ljc1OS02OS45MTggMTguNzU5IiBmaWxsPSIjZjVmNWY1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDgzMyIgZD0ibTM0NS41OCA5NDMuMDZjLTM4LjQzOCAwLTY5LjU5OC04LjM1OTktNjkuNTk4LTE4LjY3MiAwLTQuNzc2NCA2LjY4MjUtOS4xMzI0IDE3LjY3OC0xMi40MzUgMC4wNDI1IDAuMDMxMiAwLjA4MzcgMC4wNiAwLjEyNjI1IDAuMDkxMy0xMC44NzggMy4yODI4LTE3LjQ4MiA3LjYwNjUtMTcuNDgyIDEyLjM0NCAwIDEwLjI2NSAzMS4wMTYgMTguNTg2IDY5LjI3NiAxOC41ODZzNjkuMjc2LTguMzIxMyA2OS4yNzYtMTguNTg2YzAtNC43MzcyLTYuNjA2Mi05LjA2MS0xNy40ODQtMTIuMzQ0IDAuMDQzLTAuMDMxMiAwLjA4NC0wLjA2IDAuMTI2Mi0wLjA5MTMgMTAuOTk1IDMuMzAyNiAxNy42NzggNy42NTg2IDE3LjY3OCAxMi40MzUgMCAxMC4zMTItMzEuMTYgMTguNjcyLTY5LjU5NiAxOC42NzIiIGZpbGw9IiNmNWY1ZjUiLz4KICAgPHBhdGggaWQ9InBhdGg0ODM1IiBkPSJtMzQ1LjU4IDk0Mi45N2MtMzguMjYgMC02OS4yNzYtOC4zMjEzLTY5LjI3Ni0xOC41ODYgMC00LjczNzIgNi42MDUtOS4wNjEgMTcuNDgyLTEyLjM0NCAwLjA0MjUgMC4wMzEyIDAuMDgzNyAwLjA2MSAwLjEyNjI1IDAuMDkxMi0xMC43NTggMy4yNjM4LTE3LjI4OSA3LjU1MzgtMTcuMjg5IDEyLjI1MiAwIDEwLjIxNyAzMC44NzIgMTguNSA2OC45NTYgMTguNSAzOC4wODMgMCA2OC45NTUtOC4yODI4IDY4Ljk1NS0xOC41IDAtNC42OTg4LTYuNTMxMy04Ljk4ODgtMTcuMjg5LTEyLjI1MiAwLjA0My0wLjAzMDMgMC4wODQtMC4wNiAwLjEyNjMtMC4wOTEyIDEwLjg3OCAzLjI4MjggMTcuNDg0IDcuNjA2NSAxNy40ODQgMTIuMzQ0IDAgMTAuMjY1LTMxLjAxNiAxOC41ODYtNjkuMjc2IDE4LjU4NiIgZmlsbD0iI2Y0ZjRmNCIvPgogICA8cGF0aCBpZD0icGF0aDQ4MzciIGQ9Im0zNDUuNTggOTQyLjg5Yy0zOC4wODQgMC02OC45NTYtOC4yODI4LTY4Ljk1Ni0xOC41IDAtNC42OTg4IDYuNTMxMi04Ljk4ODggMTcuMjg5LTEyLjI1MiAwLjA0MTIgMC4wMjk5IDAuMDg2MiAwLjA2MjUgMC4xMjc1IDAuMDkxNC0xMC42NDIgMy4yNDM2LTE3LjA5NSA3LjUtMTcuMDk1IDEyLjE2MSAwIDEwLjE3IDMwLjcyOSAxOC40MTUgNjguNjM1IDE4LjQxNSAzNy45MDUgMCA2OC42MzQtOC4yNDUyIDY4LjYzNC0xOC40MTUgMC00LjY2MTEtNi40NTI1LTguOTE3NS0xNy4wOTUtMTIuMTYxIDAuMDQxLTAuMDI4OSAwLjA4OC0wLjA2MTUgMC4xMjc1LTAuMDkxNCAxMC43NTggMy4yNjM4IDE3LjI4OSA3LjU1MzggMTcuMjg5IDEyLjI1MiAwIDEwLjIxNy0zMC44NzIgMTguNS02OC45NTUgMTguNSIgZmlsbD0iI2Y0ZjRmNCIvPgogICA8cGF0aCBpZD0icGF0aDQ4MzkiIGQ9Im0zNDUuNTggOTQyLjhjLTM3LjkwNiAwLTY4LjYzNS04LjI0NTItNjguNjM1LTE4LjQxNSAwLTQuNjYxMSA2LjQ1MjUtOC45MTc1IDE3LjA5NS0xMi4xNjEgMC4wNDI1IDAuMDMxMiAwLjA4NSAwLjA2MSAwLjEyNzUgMC4wOTIyLTEwLjUyNCAzLjIyMzYtMTYuOTAxIDcuNDQ2NC0xNi45MDEgMTIuMDY5IDAgMTAuMTIyIDMwLjU4NSAxOC4zMjkgNjguMzE0IDE4LjMyOXM2OC4zMTQtOC4yMDYxIDY4LjMxNC0xOC4zMjljMC00LjYyMjUtNi4zNzc1LTguODQ1Mi0xNi45MDItMTIuMDY5IDAuMDQ0LTAuMDMxMiAwLjA4NS0wLjA2MSAwLjEyNzUtMC4wOTIyIDEwLjY0MiAzLjI0MzYgMTcuMDk1IDcuNSAxNy4wOTUgMTIuMTYxIDAgMTAuMTctMzAuNzI5IDE4LjQxNS02OC42MzQgMTguNDE1IiBmaWxsPSIjZjNmM2YzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg0MSIgZD0ibTM0NS41OCA5NDIuNzFjLTM3LjcyOSAwLTY4LjMxNC04LjIwNjEtNjguMzE0LTE4LjMyOSAwLTQuNjIyNSA2LjM3NzUtOC44NDUyIDE2LjkwMS0xMi4wNjkgMC4wNDI1IDAuMDMwMyAwLjA4NSAwLjA2MDEgMC4xMjc1IDAuMDkxNC0xMC40MDggMy4yMDUtMTYuNzA5IDcuMzkzNS0xNi43MDkgMTEuOTc4IDAgMTAuMDc1IDMwLjQ0MSAxOC4yNDMgNjcuOTk0IDE4LjI0MyAzNy41NTEgMCA2Ny45OTMtOC4xNjc0IDY3Ljk5My0xOC4yNDMgMC00LjU4NC02LjMwMTMtOC43NzI1LTE2LjcwOS0xMS45NzggMC4wNDMtMC4wMzEyIDAuMDg1LTAuMDYxMSAwLjEyNzUtMC4wOTE0IDEwLjUyNSAzLjIyMzYgMTYuOTAyIDcuNDQ2NCAxNi45MDIgMTIuMDY5IDAgMTAuMTIyLTMwLjU4NSAxOC4zMjktNjguMzE0IDE4LjMyOSIgZmlsbD0iI2YzZjNmMiIvPgogICA8cGF0aCBpZD0icGF0aDQ4NDMiIGQ9Im0zNDUuNTggOTQyLjYzYy0zNy41NTIgMC02Ny45OTQtOC4xNjc0LTY3Ljk5NC0xOC4yNDMgMC00LjU4NCA2LjMwMTItOC43NzI1IDE2LjcwOS0xMS45NzggMC4wNDM3IDAuMDI5OCAwLjA4NjMgMC4wNjE1IDAuMTI4NzUgMC4wOTEzLTEwLjI4OSAzLjE4NTEtMTYuNTE2IDcuMzQxMy0xNi41MTYgMTEuODg2IDAgMTAuMDI3IDMwLjI5OCAxOC4xNTYgNjcuNjcyIDE4LjE1NnM2Ny42NzEtOC4xMjg5IDY3LjY3MS0xOC4xNTZjMC00LjU0NjQtNi4yMjUtOC43MDExLTE2LjUxNi0xMS44ODYgMC4wNDMtMC4wMjk4IDAuMDg2LTAuMDYxNSAwLjEyODctMC4wOTEzIDEwLjQwOCAzLjIwNSAxNi43MDkgNy4zOTM1IDE2LjcwOSAxMS45NzggMCAxMC4wNzUtMzAuNDQxIDE4LjI0My02Ny45OTMgMTguMjQzIiBmaWxsPSIjZjJmMmYyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg0NSIgZD0ibTM0NS41OCA5NDIuNTRjLTM3LjM3NSAwLTY3LjY3Mi04LjEyODktNjcuNjcyLTE4LjE1NiAwLTQuNTQ0OSA2LjIyNzUtOC43MDExIDE2LjUxNi0xMS44ODYgMC4wNDI1IDAuMDI5OCAwLjA4NjMgMC4wNjEgMC4xMjg3NSAwLjA5MTQtMTAuMTcyIDMuMTY1LTE2LjMyNCA3LjI4NzUtMTYuMzI0IDExLjc5NSAwIDkuOTggMzAuMTU0IDE4LjA3IDY3LjM1MSAxOC4wNyAzNy4xOTYgMCA2Ny4zNTEtOC4wODk4IDY3LjM1MS0xOC4wNyAwLTQuNTA3NC02LjE1MTItOC42Mjk5LTE2LjMyNS0xMS43OTUgMC4wNDMtMC4wMzA0IDAuMDg2LTAuMDYxNiAwLjEyODgtMC4wOTE0IDEwLjI5MSAzLjE4NTEgMTYuNTE2IDcuMzM5OSAxNi41MTYgMTEuODg2IDAgMTAuMDI3LTMwLjI5OCAxOC4xNTYtNjcuNjcxIDE4LjE1NiIgZmlsbD0iI2YyZjFmMSIvPgogICA8cGF0aCBpZD0icGF0aDQ4NDciIGQ9Im0zNDUuNTggOTQyLjQ2Yy0zNy4xOTggMC02Ny4zNTEtOC4wODk4LTY3LjM1MS0xOC4wNyAwLTQuNTA3NCA2LjE1MTItOC42Mjk5IDE2LjMyNC0xMS43OTUgMC4wNDM4IDAuMDMxMiAwLjA4NzUgMC4wNjEgMC4xMyAwLjA5MjMtMTAuMDU2IDMuMTQ1LTE2LjEzNCA3LjIzMzktMTYuMTM0IDExLjcwMyAwIDkuOTMyNiAzMC4wMTEgMTcuOTg0IDY3LjAzMSAxNy45ODRzNjcuMDMtOC4wNTEzIDY3LjAzLTE3Ljk4NGMwLTQuNDY4OC02LjA3NzUtOC41NTc2LTE2LjEzNC0xMS43MDMgMC4wNDQtMC4wMzEyIDAuMDg2LTAuMDYxIDAuMTMtMC4wOTIzIDEwLjE3NCAzLjE2NSAxNi4zMjUgNy4yODc1IDE2LjMyNSAxMS43OTUgMCA5Ljk4LTMwLjE1NSAxOC4wNy02Ny4zNTEgMTguMDciIGZpbGw9IiNmMWYxZjEiLz4KICAgPHBhdGggaWQ9InBhdGg0ODQ5IiBkPSJtMzQ1LjU4IDk0Mi4zN2MtMzcuMDIgMC02Ny4wMzEtOC4wNTEzLTY3LjAzMS0xNy45ODQgMC00LjQ2ODggNi4wNzc1LTguNTU3NiAxNi4xMzQtMTEuNzAzIDAuMDQzOCAwLjAzMDMgMC4wODc1IDAuMDYxNSAwLjEzIDAuMDkxMy05Ljk0IDMuMTI1LTE1Ljk0MiA3LjE4MDItMTUuOTQyIDExLjYxMSAwIDkuODg0OCAyOS44NjYgMTcuODk3IDY2LjcxIDE3Ljg5NyAzNi44NDMgMCA2Ni43MS04LjAxMjcgNjYuNzEtMTcuODk3IDAtNC40MzExLTYuMDAzOC04LjQ4NjQtMTUuOTQ0LTExLjYxMSAwLjA0NC0wLjAyOTcgMC4wODgtMC4wNjEgMC4xMy0wLjA5MTMgMTAuMDU2IDMuMTQ1IDE2LjEzNCA3LjIzMzkgMTYuMTM0IDExLjcwMyAwIDkuOTMyNi0zMC4wMSAxNy45ODQtNjcuMDMgMTcuOTg0IiBmaWxsPSIjZjBmMGYwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg1MSIgZD0ibTM0NS41OCA5NDIuMjhjLTM2Ljg0NCAwLTY2LjcxLTguMDEyNy02Ni43MS0xNy44OTcgMC00LjQzMTEgNi4wMDI1LTguNDg2NCAxNS45NDItMTEuNjExIDAuMDQzOCAwLjAzMDQgMC4wODc1IDAuMDYxNiAwLjEzMTI1IDAuMDkxNC05LjgyNSAzLjEwNjQtMTUuNzU0IDcuMTI3NC0xNS43NTQgMTEuNTIgMCA5LjgzNzQgMjkuNzI0IDE3LjgxMiA2Ni4zOSAxNy44MTIgMzYuNjY1IDAgNjYuMzg5LTcuOTc1MSA2Ni4zODktMTcuODEyIDAtNC4zOTI2LTUuOTI4Ny04LjQxMzYtMTUuNzUyLTExLjUyIDAuMDQzLTAuMDI5OCAwLjA4OC0wLjA2MSAwLjEzLTAuMDkxNCA5Ljk0IDMuMTI1IDE1Ljk0NCA3LjE4MDIgMTUuOTQ0IDExLjYxMSAwIDkuODg0OC0yOS44NjggMTcuODk3LTY2LjcxIDE3Ljg5NyIgZmlsbD0iI2YwZjBlZiIvPgogICA8cGF0aCBpZD0icGF0aDQ4NTMiIGQ9Im0zNDUuNTggOTQyLjJjLTM2LjY2NiAwLTY2LjM5LTcuOTc1MS02Ni4zOS0xNy44MTIgMC00LjM5MjYgNS45Mjg4LTguNDEzNiAxNS43NTQtMTEuNTIgMC4wNDUgMC4wMzEyIDAuMDg1IDAuMDYgMC4xMzEyNSAwLjA5MTItOS43MTEyIDMuMDg2NS0xNS41NjQgNy4wNzM4LTE1LjU2NCAxMS40MjkgMCA5Ljc5IDI5LjU4IDE3LjcyNiA2Ni4wNjkgMTcuNzI2IDM2LjQ4OCAwIDY2LjA2OC03LjkzNjEgNjYuMDY4LTE3LjcyNiAwLTQuMzU1LTUuODUyNS04LjM0MjItMTUuNTYyLTExLjQyOSAwLjA0NS0wLjAzMTIgMC4wODUtMC4wNiAwLjEzMTItMC4wOTEyIDkuODIzOCAzLjEwNjQgMTUuNzUyIDcuMTI3NCAxNS43NTIgMTEuNTIgMCA5LjgzNzQtMjkuNzI0IDE3LjgxMi02Ni4zODkgMTcuODEyIiBmaWxsPSIjZWZlZmVmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg1NSIgZD0ibTM0NS41OCA5NDIuMTFjLTM2LjQ4OSAwLTY2LjA2OS03LjkzNjEtNjYuMDY5LTE3LjcyNiAwLTQuMzU1IDUuODUyNS04LjM0MjIgMTUuNTY0LTExLjQyOSAwLjA0MjUgMC4wMjk5IDAuMDg3NSAwLjA2MjUgMC4xMzEyNSAwLjA5MjQtOS41OTM4IDMuMDY0OS0xNS4zNzQgNy4wMi0xNS4zNzQgMTEuMzM2IDAgOS43NDI2IDI5LjQzNiAxNy42NCA2NS43NDggMTcuNjQgMzYuMzExIDAgNjUuNzQ4LTcuODk3NSA2NS43NDgtMTcuNjQgMC00LjMxNjQtNS43OC04LjI3MTUtMTUuMzc1LTExLjMzNiAwLjA0NC0wLjAyOTkgMC4wODktMC4wNjI1IDAuMTMyNS0wLjA5MjQgOS43MSAzLjA4NjUgMTUuNTYyIDcuMDczOCAxNS41NjIgMTEuNDI5IDAgOS43OS0yOS41OCAxNy43MjYtNjYuMDY4IDE3LjcyNiIgZmlsbD0iI2VlZSIvPgogICA8cGF0aCBpZD0icGF0aDQ4NTciIGQ9Im0zNDUuNTggOTQyLjAzYy0zNi4zMTEgMC02NS43NDgtNy44OTc1LTY1Ljc0OC0xNy42NCAwLTQuMzE2NCA1Ljc4LTguMjcxNSAxNS4zNzQtMTEuMzM2IDAuMDQzNyAwLjAzMDIgMC4wODg4IDAuMDYxNSAwLjEzMjUgMC4wOTEyLTkuNDgyNSAzLjA0NjQtMTUuMTg2IDYuOTY2NC0xNS4xODYgMTEuMjQ1IDAgOS42OTQ5IDI5LjI5MiAxNy41NTQgNjUuNDI4IDE3LjU1NCAzNi4xMzQgMCA2NS40MjYtNy44NTg4IDY1LjQyNi0xNy41NTQgMC00LjI3ODgtNS43MDM3LTguMTk4OC0xNS4xODYtMTEuMjQ1IDAuMDQ0LTAuMDI5NyAwLjA5LTAuMDYxIDAuMTMyNS0wLjA5MTIgOS41OTUgMy4wNjQ5IDE1LjM3NSA3LjAyIDE1LjM3NSAxMS4zMzYgMCA5Ljc0MjYtMjkuNDM2IDE3LjY0LTY1Ljc0OCAxNy42NCIgZmlsbD0iI2VlZWVlZCIvPgogICA8cGF0aCBpZD0icGF0aDQ4NTkiIGQ9Im0zNDUuNTggOTQxLjk0Yy0zNi4xMzUgMC02NS40MjgtNy44NTg4LTY1LjQyOC0xNy41NTQgMC00LjI3ODggNS43MDM4LTguMTk4OCAxNS4xODYtMTEuMjQ1IDAuMDQ1IDAuMDMxMiAwLjA4NjMgMC4wNjAxIDAuMTMyNSAwLjA5MTQtOS4zNjc1IDMuMDI2Mi0xNC45OTggNi45MTM1LTE0Ljk5OCAxMS4xNTQgMCA5LjY0NzUgMjkuMTQ5IDE3LjQ2NyA2NS4xMDYgMTcuNDY3IDM1Ljk1NiAwIDY1LjEwNi03LjgxOTggNjUuMTA2LTE3LjQ2NyAwLTQuMjQwMi01LjYzMTItOC4xMjc1LTE0Ljk5OS0xMS4xNTQgMC4wNDYtMC4wMzEyIDAuMDg4LTAuMDYwMSAwLjEzMjUtMC4wOTE0IDkuNDgyNSAzLjA0NjQgMTUuMTg2IDYuOTY2NCAxNS4xODYgMTEuMjQ1IDAgOS42OTQ5LTI5LjI5MiAxNy41NTQtNjUuNDI2IDE3LjU1NCIgZmlsbD0iI2VkZWRlYyIvPgogICA8cGF0aCBpZD0icGF0aDQ4NjEiIGQ9Im0zNDUuNTggOTQxLjg1Yy0zNS45NTggMC02NS4xMDYtNy44MTk4LTY1LjEwNi0xNy40NjcgMC00LjI0MDIgNS42My04LjEyNzUgMTQuOTk4LTExLjE1NCAwLjA0MzcgMC4wMzAzIDAuMDkgMC4wNjI1IDAuMTMzNzUgMC4wOTIzLTkuMjUxMiAzLjAwNTQtMTQuODExIDYuODU4OS0xNC44MTEgMTEuMDYyIDAgOS42MDAxIDI5LjAwNiAxNy4zODEgNjQuNzg2IDE3LjM4MXM2NC43ODUtNy43ODEyIDY0Ljc4NS0xNy4zODFjMC00LjIwMjYtNS41Ni04LjA1NjEtMTQuODExLTExLjA2MiAwLjA0NC0wLjAyOTggMC4wOTEtMC4wNjIgMC4xMzM4LTAuMDkyMyA5LjM2NzUgMy4wMjYyIDE0Ljk5OSA2LjkxMzUgMTQuOTk5IDExLjE1NCAwIDkuNjQ3NS0yOS4xNSAxNy40NjctNjUuMTA2IDE3LjQ2NyIgZmlsbD0iI2VkZWRlYyIvPgogICA8cGF0aCBpZD0icGF0aDQ4NjMiIGQ9Im0zNDUuNTggOTQxLjc3Yy0zNS43OCAwLTY0Ljc4Ni03Ljc4MTItNjQuNzg2LTE3LjM4MSAwLTQuMjAyNiA1LjU2LTguMDU2MSAxNC44MTEtMTEuMDYyIDAuMDQ1IDAuMDMxNyAwLjA4NzUgMC4wNiAwLjEzMzc1IDAuMDkxMi05LjEzODggMi45ODU0LTE0LjYyNCA2LjgwNjYtMTQuNjI0IDEwLjk3IDAgOS41NTI4IDI4Ljg2MSAxNy4yOTUgNjQuNDY1IDE3LjI5NSAzNS42MDMgMCA2NC40NjQtNy43NDIyIDY0LjQ2NC0xNy4yOTUgMC00LjE2MzYtNS40ODUtNy45ODQ5LTE0LjYyNC0xMC45NyAwLjA0Ni0wLjAzMTIgMC4wODktMC4wNTk1IDAuMTMzNy0wLjA5MTIgOS4yNTEzIDMuMDA1NCAxNC44MTEgNi44NTg5IDE0LjgxMSAxMS4wNjIgMCA5LjYwMDEtMjkuMDA1IDE3LjM4MS02NC43ODUgMTcuMzgxIiBmaWxsPSIjZWNlY2ViIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg2NSIgZD0ibTM0NS41OCA5NDEuNjhjLTM1LjYwNCAwLTY0LjQ2NS03Ljc0MjItNjQuNDY1LTE3LjI5NSAwLTQuMTYzNiA1LjQ4NS03Ljk4NDkgMTQuNjI0LTEwLjk3IDAuMDQzOCAwLjAzMDMgMC4wOTEzIDAuMDYyNSAwLjEzMzc1IDAuMDkyOC05LjAyMzggMi45NjM5LTE0LjQzNiA2Ljc1MS0xNC40MzYgMTAuODc4IDAgOS41MDQ5IDI4LjcxOCAxNy4yMSA2NC4xNDQgMTcuMjEgMzUuNDI1IDAgNjQuMTQ0LTcuNzA1MSA2NC4xNDQtMTcuMjEgMC00LjEyNjUtNS40MTM3LTcuOTEzNi0xNC40MzgtMTAuODc5IDAuMDQ0LTAuMDI4OSAwLjA5MS0wLjA2MTEgMC4xMzM4LTAuMDkxNCA5LjEzODcgMi45ODU0IDE0LjYyNCA2LjgwNjYgMTQuNjI0IDEwLjk3IDAgOS41NTI4LTI4Ljg2MSAxNy4yOTUtNjQuNDY0IDE3LjI5NSIgZmlsbD0iI2VjZWJlYSIvPgogICA8cGF0aCBpZD0icGF0aDQ4NjciIGQ9Im0zNDUuNTggOTQxLjZjLTM1LjQyNiAwLTY0LjE0NC03LjcwNTEtNjQuMTQ0LTE3LjIxIDAtNC4xMjY1IDUuNDEyNS03LjkxMzYgMTQuNDM2LTEwLjg3OCAwLjA0NjMgMC4wMzEyIDAuMDkgMC4wNjAxIDAuMTM1IDAuMDkxNC04LjkxMTIgMi45NDM5LTE0LjI1MSA2LjY5NzItMTQuMjUxIDEwLjc4NiAwIDkuNDU3NSAyOC41NzUgMTcuMTI0IDYzLjgyNCAxNy4xMjQgMzUuMjQ4IDAgNjMuODIzLTcuNjY2IDYzLjgyMy0xNy4xMjQgMC00LjA4ODktNS4zNC03Ljg0MjItMTQuMjUxLTEwLjc4NiAwLjA0Ni0wLjAzMTIgMC4wODktMC4wNjE1IDAuMTM1LTAuMDkyOCA5LjAyMzggMi45NjUyIDE0LjQzOCA2Ljc1MjQgMTQuNDM4IDEwLjg3OSAwIDkuNTA0OS0yOC43MTkgMTcuMjEtNjQuMTQ0IDE3LjIxIiBmaWxsPSIjZWJlYmVhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg2OSIgZD0ibTM0NS41OCA5NDEuNTFjLTM1LjI0OSAwLTYzLjgyNC03LjY2Ni02My44MjQtMTcuMTI0IDAtNC4wODg5IDUuMzQtNy44NDIyIDE0LjI1MS0xMC43ODYgMC4wNDM3IDAuMDI5NyAwLjA5MjUgMC4wNjI1IDAuMTM2MjUgMC4wOTEyLTguNzk4OCAyLjkyNDktMTQuMDY2IDYuNjQ1Mi0xNC4wNjYgMTAuNjk1IDAgOS40MTAxIDI4LjQzIDE3LjAzOCA2My41MDIgMTcuMDM4IDM1LjA3MSAwIDYzLjUwMS03LjYyNzUgNjMuNTAxLTE3LjAzOCAwLTQuMDQ5OC01LjI2NzUtNy43Ny0xNC4wNjYtMTAuNjk1IDAuMDQ0LTAuMDI4NyAwLjA5Mi0wLjA2MTUgMC4xMzYyLTAuMDkxMiA4LjkxMTMgMi45NDM5IDE0LjI1MSA2LjY5NzIgMTQuMjUxIDEwLjc4NiAwIDkuNDU3NS0yOC41NzUgMTcuMTI0LTYzLjgyMyAxNy4xMjQiIGZpbGw9IiNlYWVhZTkiLz4KICAgPHBhdGggaWQ9InBhdGg0ODcxIiBkPSJtMzQ1LjU4IDk0MS40MmMtMzUuMDcyIDAtNjMuNTAyLTcuNjI3NS02My41MDItMTcuMDM4IDAtNC4wNDk4IDUuMjY3NS03Ljc3IDE0LjA2Ni0xMC42OTUgMC4wNDYyIDAuMDMxMiAwLjA5IDAuMDYxMSAwLjEzNjI1IDAuMDkyNC04LjY4NSAyLjkwMzgtMTMuODgyIDYuNTg5OC0xMy44ODIgMTAuNjAyIDAgOS4zNjIyIDI4LjI4OCAxNi45NTEgNjMuMTgyIDE2Ljk1MXM2My4xODEtNy41ODg5IDYzLjE4MS0xNi45NTFjMC00LjAxMjgtNS4xOTc1LTcuNjk4OC0xMy44ODItMTAuNjAyIDAuMDQ2LTAuMDMxMiAwLjA5LTAuMDYxMSAwLjEzNjMtMC4wOTI0IDguNzk4NyAyLjkyNDkgMTQuMDY2IDYuNjQ1MSAxNC4wNjYgMTAuNjk1IDAgOS40MTAxLTI4LjQzIDE3LjAzOC02My41MDEgMTcuMDM4IiBmaWxsPSIjZWFlYWU4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDg3MyIgZD0ibTM0NS41OCA5NDEuMzRjLTM0Ljg5NSAwLTYzLjE4Mi03LjU4ODktNjMuMTgyLTE2Ljk1MSAwLTQuMDEyOCA1LjE5NzUtNy42OTg4IDEzLjg4Mi0xMC42MDIgMC4wNDYyIDAuMDMxMiAwLjA5IDAuMDYxIDAuMTM2MjUgMC4wOTEzLTguNTcyNSAyLjg4MzgtMTMuNjk4IDYuNTM3Ni0xMy42OTggMTAuNTExIDAgOS4zMTUgMjguMTQ0IDE2Ljg2NSA2Mi44NjEgMTYuODY1IDM0LjcxNiAwIDYyLjg2LTcuNTQ5OCA2Mi44Ni0xNi44NjUgMC0zLjk3MzYtNS4xMjUtNy42Mjc1LTEzLjY5OC0xMC41MTEgMC4wNDYtMC4wMzAzIDAuMDktMC4wNiAwLjEzNjItMC4wOTEzIDguNjg1IDIuOTAzOCAxMy44ODIgNi41ODk4IDEzLjg4MiAxMC42MDIgMCA5LjM2MjItMjguMjg4IDE2Ljk1MS02My4xODEgMTYuOTUxIiBmaWxsPSIjZTllOWU4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDg3NSIgZD0ibTM0NS41OCA5NDEuMjVjLTM0LjcxOCAwLTYyLjg2MS03LjU0OTgtNjIuODYxLTE2Ljg2NSAwLTMuOTczNiA1LjEyNS03LjYyNzUgMTMuNjk4LTEwLjUxMSAwLjA0NjIgMC4wMzEyIDAuMDkxMyAwLjA2MSAwLjEzNzUgMC4wOTIzLTguNDYgMi44NjI5LTEzLjUxNCA2LjQ4My0xMy41MTQgMTAuNDE5IDAgOS4yNjc2IDI4IDE2Ljc3OSA2Mi41NCAxNi43NzlzNjIuNTQtNy41MTEyIDYyLjU0LTE2Ljc3OWMwLTMuOTM2LTUuMDU1LTcuNTU2MS0xMy41MTUtMTAuNDE5IDAuMDQ2LTAuMDMxMiAwLjA5MS0wLjA2MSAwLjEzNzUtMC4wOTIzIDguNTcyNSAyLjg4MzggMTMuNjk4IDYuNTM3NiAxMy42OTggMTAuNTExIDAgOS4zMTUtMjguMTQ0IDE2Ljg2NS02Mi44NiAxNi44NjUiIGZpbGw9IiNlOWU4ZTciLz4KICAgPHBhdGggaWQ9InBhdGg0ODc3IiBkPSJtMzQ1LjU4IDk0MS4xNmMtMzQuNTQgMC02Mi41NC03LjUxMTItNjIuNTQtMTYuNzc5IDAtMy45MzYgNS4wNTM4LTcuNTU2MSAxMy41MTQtMTAuNDE5IDAuMDQ2MiAwLjAzMTIgMC4wOTI1IDAuMDYxNiAwLjEzODc1IDAuMDkyOS04LjM1IDIuODQxMy0xMy4zMzIgNi40MjczLTEzLjMzMiAxMC4zMjYgMCA5LjIyMDIgMjcuODU2IDE2LjY5NCA2Mi4yMiAxNi42OTQgMzQuMzYzIDAgNjIuMjE5LTcuNDczNiA2Mi4yMTktMTYuNjk0IDAtMy44OTg5LTQuOTgyNS03LjQ4NDktMTMuMzMyLTEwLjMyNiAwLjA0Ny0wLjAzMTIgMC4wOTItMC4wNjE2IDAuMTM4OC0wLjA5MjkgOC40NiAyLjg2MjkgMTMuNTE1IDYuNDgzIDEzLjUxNSAxMC40MTkgMCA5LjI2NzYtMjggMTYuNzc5LTYyLjU0IDE2Ljc3OSIgZmlsbD0iI2U4ZThlNiIvPgogICA8cGF0aCBpZD0icGF0aDQ4NzkiIGQ9Im0zNDUuNTggOTQxLjA4Yy0zNC4zNjQgMC02Mi4yMi03LjQ3MzYtNjIuMjItMTYuNjk0IDAtMy44OTg5IDQuOTgyNS03LjQ4NDkgMTMuMzMyLTEwLjMyNiAwLjA0NjIgMC4wMjk4IDAuMDkxMyAwLjA2IDAuMTM3NSAwLjA5MTMtOC4yMzYyIDIuODE5OS0xMy4xNDkgNi4zNzM1LTEzLjE0OSAxMC4yMzUgMCA5LjE3MjQgMjcuNzEyIDE2LjYwNyA2MS44OTkgMTYuNjA3IDM0LjE4NSAwIDYxLjg5OC03LjQzNTEgNjEuODk4LTE2LjYwNyAwLTMuODYxNC00LjkxMTMtNy40MTUtMTMuMTQ5LTEwLjIzNSAwLjA0Ni0wLjAzMTIgMC4wOTItMC4wNjE1IDAuMTM3NS0wLjA5MTMgOC4zNSAyLjg0MTMgMTMuMzMyIDYuNDI3MyAxMy4zMzIgMTAuMzI2IDAgOS4yMjAyLTI3Ljg1NiAxNi42OTQtNjIuMjE5IDE2LjY5NCIgZmlsbD0iI2U3ZTdlNiIvPgogICA8cGF0aCBpZD0icGF0aDQ4ODEiIGQ9Im0zNDUuNTggOTQwLjk5Yy0zNC4xODYgMC02MS44OTktNy40MzUxLTYxLjg5OS0xNi42MDcgMC0zLjg2MTQgNC45MTI1LTcuNDE1IDEzLjE0OS0xMC4yMzUgMC4wNDc1IDAuMDMxMiAwLjA5MzcgMC4wNjEgMC4xNCAwLjA5MjItOC4xMjYyIDIuNzk4OS0xMi45NjkgNi4zMTk5LTEyLjk2OSAxMC4xNDMgMCA5LjEyMzUgMjcuNTcgMTYuNTIxIDYxLjU3OSAxNi41MjEgMzQuMDA4IDAgNjEuNTc4LTcuMzk4IDYxLjU3OC0xNi41MjEgMC0zLjgyMzgtNC44NC03LjM0MzgtMTIuOTY5LTEwLjE0MyAwLjA0Ni0wLjAzMTIgMC4wOTQtMC4wNjEgMC4xNC0wLjA5MjIgOC4yMzc1IDIuODE5OSAxMy4xNDkgNi4zNzM1IDEzLjE0OSAxMC4yMzUgMCA5LjE3MjQtMjcuNzEyIDE2LjYwNy02MS44OTggMTYuNjA3IiBmaWxsPSIjZTdlNmU1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNDg4MyIgZD0ibTM0NS41OCA5NDAuOTFjLTM0LjAwOSAwLTYxLjU3OS03LjM5OC02MS41NzktMTYuNTIxIDAtMy44MjI4IDQuODQyNS03LjM0MzggMTIuOTY5LTEwLjE0MyAwLjA0NjMgMC4wMzA0IDAuMDkzNyAwLjA2MTYgMC4xNCAwLjA5MTQtOC4wMTYyIDIuNzc4OC0xMi43ODggNi4yNjYxLTEyLjc4OCAxMC4wNTEgMCA5LjA3NzYgMjcuNDI2IDE2LjQzNSA2MS4yNTggMTYuNDM1IDMzLjgzMSAwIDYxLjI1Ni03LjM1NzQgNjEuMjU2LTE2LjQzNSAwLTMuNzg1MS00Ljc3MTItNy4yNzI1LTEyLjc4Ni0xMC4wNTEgMC4wNDYtMC4wMjk4IDAuMDkzLTAuMDYxIDAuMTM4Ny0wLjA5MTQgOC4xMjg4IDIuNzk4OSAxMi45NjkgNi4zMTg5IDEyLjk2OSAxMC4xNDMgMCA5LjEyMzUtMjcuNTcgMTYuNTIxLTYxLjU3OCAxNi41MjEiIGZpbGw9IiNlNmU2ZTQiLz4KICAgPHBhdGggaWQ9InBhdGg0ODg1IiBkPSJtMzQ1LjU4IDk0MC44MmMtMzMuODMxIDAtNjEuMjU4LTcuMzU3NC02MS4yNTgtMTYuNDM1IDAtMy43ODUxIDQuNzcxMi03LjI3MjUgMTIuNzg4LTEwLjA1MSAwLjA0NjIgMC4wMzEyIDAuMDkzNyAwLjA2MjUgMC4xNCAwLjA5MjItNy45MDYyIDIuNzU3OS0xMi42MDYgNi4yMTE1LTEyLjYwNiA5Ljk1OSAwIDkuMDI4OCAyNy4yODEgMTYuMzQ5IDYwLjkzNiAxNi4zNDkgMzMuNjU0IDAgNjAuOTM2LTcuMzE5OSA2MC45MzYtMTYuMzQ5IDAtMy43NDc1LTQuNzAxMi03LjIwMTEtMTIuNjA4LTkuOTU5IDAuMDQ2LTAuMDI5NyAwLjA5NC0wLjA2MSAwLjE0MTMtMC4wOTIyIDguMDE1IDIuNzc4OCAxMi43ODYgNi4yNjYxIDEyLjc4NiAxMC4wNTEgMCA5LjA3NzYtMjcuNDI1IDE2LjQzNS02MS4yNTYgMTYuNDM1IiBmaWxsPSIjZTVlNWUzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg4NyIgZD0ibTM0NS41OCA5NDAuNzNjLTMzLjY1NSAwLTYwLjkzNi03LjMxOTktNjAuOTM2LTE2LjM0OSAwLTMuNzQ3NSA0LjctNy4yMDExIDEyLjYwNi05Ljk1OSAwLjA0NjIgMC4wMzAzIDAuMDk1IDAuMDYxNSAwLjE0MTI1IDAuMDkyOC03Ljc5NjIgMi43MzY0LTEyLjQyOCA2LjE1NjItMTIuNDI4IDkuODY2MiAwIDguOTgxNSAyNy4xMzkgMTYuMjYzIDYwLjYxNiAxNi4yNjMgMzMuNDc2IDAgNjAuNjE1LTcuMjgxMiA2MC42MTUtMTYuMjYzIDAtMy43MS00LjYzMTMtNy4xMjk5LTEyLjQyOC05Ljg2NjIgMC4wNDYtMC4wMzEyIDAuMDk1LTAuMDYyNSAwLjE0MTItMC4wOTI4IDcuOTA2MyAyLjc1NzkgMTIuNjA4IDYuMjExNSAxMi42MDggOS45NTkgMCA5LjAyODgtMjcuMjgyIDE2LjM0OS02MC45MzYgMTYuMzQ5IiBmaWxsPSIjZTRlNGUzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDg4OSIgZD0ibTM0NS41OCA5NDAuNjVjLTMzLjQ3OCAwLTYwLjYxNi03LjI4MTItNjAuNjE2LTE2LjI2MyAwLTMuNzEgNC42MzEyLTcuMTI5OSAxMi40MjgtOS44NjYyIDAuMDQ2MiAwLjAyOTkgMC4wOTUgMC4wNjExIDAuMTQxMjUgMC4wOTE0LTcuNjg2MiAyLjcxNjItMTIuMjQ4IDYuMTAyNS0xMi4yNDggOS43NzQ5IDAgOC45MzM2IDI2Ljk5NSAxNi4xNzYgNjAuMjk1IDE2LjE3NiAzMy4yOTkgMCA2MC4yOTQtNy4yNDI2IDYwLjI5NC0xNi4xNzYgMC0zLjY3MjQtNC41NjEyLTcuMDU4Ni0xMi4yNDgtOS43NzQ5IDAuMDQ2LTAuMDMwMiAwLjA5NS0wLjA2MTUgMC4xNDEzLTAuMDkxNCA3Ljc5NjIgMi43MzY0IDEyLjQyOCA2LjE1NjIgMTIuNDI4IDkuODY2MiAwIDguOTgxNS0yNy4xMzkgMTYuMjYzLTYwLjYxNSAxNi4yNjMiIGZpbGw9IiNlNGUzZTIiLz4KICAgPHBhdGggaWQ9InBhdGg0ODkxIiBkPSJtMzQ1LjU4IDk0MC41NmMtMzMuMyAwLTYwLjI5NS03LjI0MjYtNjAuMjk1LTE2LjE3NiAwLTMuNjcyNCA0LjU2MTItNy4wNTg2IDEyLjI0OC05Ljc3NDkgMC4wNDg4IDAuMDMyMyAwLjA5MzcgMC4wNjEgMC4xNDI1IDAuMDkyMi03LjU3NzUgMi42OTM5LTEyLjA2OSA2LjA0NzktMTIuMDY5IDkuNjgyNiAwIDguODg2MiAyNi44NSAxNi4wOTEgNTkuOTc0IDE2LjA5MSAzMy4xMjMgMCA1OS45NzQtNy4yMDUxIDU5Ljk3NC0xNi4wOTEgMC0zLjYzNDgtNC40OTI1LTYuOTg4OC0xMi4wNy05LjY4MjYgMC4wNDktMC4wMzEyIDAuMDk0LTAuMDYgMC4xNDI1LTAuMDkyMiA3LjY4NjMgMi43MTYyIDEyLjI0OCA2LjEwMjUgMTIuMjQ4IDkuNzc0OSAwIDguOTMzNi0yNi45OTUgMTYuMTc2LTYwLjI5NCAxNi4xNzYiIGZpbGw9IiNlM2UzZTEiLz4KICAgPHBhdGggaWQ9InBhdGg0ODkzIiBkPSJtMzQ1LjU4IDk0MC40OGMtMzMuMTI0IDAtNTkuOTc0LTcuMjA1MS01OS45NzQtMTYuMDkxIDAtMy42MzQ4IDQuNDkxMi02Ljk4ODggMTIuMDY5LTkuNjgyNiAwLjA0NjIgMC4wMzAzIDAuMDk2MiAwLjA2MjUgMC4xNDM3NSAwLjA5MjgtNy40Njc1IDIuNjcyNC0xMS44OTIgNS45OTIyLTExLjg5MiA5LjU4OTkgMCA4LjgzODkgMjYuNzA4IDE2LjAwNSA1OS42NTQgMTYuMDA1IDMyLjk0NSAwIDU5LjY1My03LjE2NiA1OS42NTMtMTYuMDA1IDAtMy41OTc2LTQuNDIzOC02LjkxNzUtMTEuODkxLTkuNTg5OSAwLjA0Ni0wLjAzMDMgMC4wOTYtMC4wNjI1IDAuMTQyNS0wLjA5MjggNy41Nzc1IDIuNjkzOSAxMi4wNyA2LjA0NzkgMTIuMDcgOS42ODI2IDAgOC44ODYyLTI2Ljg1MSAxNi4wOTEtNTkuOTc0IDE2LjA5MSIgZmlsbD0iI2UyZTJlMCIvPgogICA8cGF0aCBpZD0icGF0aDQ4OTUiIGQ9Im0zNDUuNTggOTQwLjM5Yy0zMi45NDYgMC01OS42NTQtNy4xNjYtNTkuNjU0LTE2LjAwNSAwLTMuNTk3NiA0LjQyNS02LjkxNzUgMTEuODkyLTkuNTg5OSAwLjA0ODggMC4wMzEyIDAuMDkzNyAwLjA2MDEgMC4xNDI1IDAuMDkwOS03LjM1ODggMi42NTI4LTExLjcxNCA1LjkzOS0xMS43MTQgOS40OTkgMCA4Ljc5MSAyNi41NjQgMTUuOTE5IDU5LjMzMiAxNS45MTlzNTkuMzMxLTcuMTI4IDU5LjMzMS0xNS45MTljMC0zLjU2LTQuMzU1LTYuODQ2Mi0xMS43MTQtOS40OTkgMC4wNDktMC4wMzA4IDAuMDk0LTAuMDU5NiAwLjE0MzctMC4wOTA5IDcuNDY3NSAyLjY3MjQgMTEuODkxIDUuOTkyMiAxMS44OTEgOS41ODk5IDAgOC44Mzg5LTI2LjcwOCAxNi4wMDUtNTkuNjUzIDE2LjAwNSIgZmlsbD0iI2UyZTFkZiIvPgogICA8cGF0aCBpZD0icGF0aDQ4OTciIGQ9Im0zNDUuNTggOTQwLjNjLTMyLjc2OSAwLTU5LjMzMi03LjEyOC01OS4zMzItMTUuOTE5IDAtMy41NiA0LjM1NS02Ljg0NjIgMTEuNzE0LTkuNDk5IDAuMDQ3NSAwLjAzMDMgMC4wOTc1IDAuMDYzIDAuMTQ1IDAuMDkyOC03LjI1MTIgMi42Mjk4LTExLjUzOSA1Ljg4MzctMTEuNTM5IDkuNDA2MiAwIDguNzQzNiAyNi40MjEgMTUuODMzIDU5LjAxMiAxNS44MzNzNTkuMDExLTcuMDg4OSA1OS4wMTEtMTUuODMzYzAtMy41MjI1LTQuMjg3NS02Ljc3NjQtMTEuNTM5LTkuNDA2MiAwLjA0OC0wLjAyOTggMC4wOTgtMC4wNjI1IDAuMTQ1LTAuMDkyOCA3LjM1ODcgMi42NTI4IDExLjcxNCA1LjkzOSAxMS43MTQgOS40OTkgMCA4Ljc5MS0yNi41NjQgMTUuOTE5LTU5LjMzMSAxNS45MTkiIGZpbGw9IiNlMWUwZGYiLz4KICAgPHBhdGggaWQ9InBhdGg0ODk5IiBkPSJtMzQ1LjU4IDk0MC4yMmMtMzIuNTkxIDAtNTkuMDEyLTcuMDg4OS01OS4wMTItMTUuODMzIDAtMy41MjI1IDQuMjg3NS02Ljc3NjQgMTEuNTM5LTkuNDA2MiAwLjA0ODcgMC4wMzEyIDAuMDk1IDAuMDYxIDAuMTQ1IDAuMDkyMi03LjE0MjUgMi42MDg5LTExLjM2MiA1LjgyOTEtMTEuMzYyIDkuMzE0IDAgOC42OTYyIDI2LjI3NiAxNS43NDYgNTguNjkxIDE1Ljc0NiAzMi40MTQgMCA1OC42OS03LjA0OTggNTguNjktMTUuNzQ2IDAtMy40ODQ5LTQuMjItNi43MDUxLTExLjM2Mi05LjMxNCAwLjA1LTAuMDMxMiAwLjA5Ni0wLjA2MSAwLjE0NS0wLjA5MjIgNy4yNTEyIDIuNjI5OSAxMS41MzkgNS44ODM4IDExLjUzOSA5LjQwNjIgMCA4Ljc0MzYtMjYuNDIgMTUuODMzLTU5LjAxMSAxNS44MzMiIGZpbGw9IiNlMGUwZGUiLz4KICAgPHBhdGggaWQ9InBhdGg0OTAxIiBkPSJtMzQ1LjU4IDk0MC4xM2MtMzIuNDE1IDAtNTguNjkxLTcuMDQ5OC01OC42OTEtMTUuNzQ2IDAtMy40ODQ5IDQuMjItNi43MDUxIDExLjM2Mi05LjMxNCAwLjA0NjMgMC4wMzAzIDAuMDk4NyAwLjA2MjUgMC4xNDUgMC4wOTI4LTcuMDM2MiAyLjU4Ni0xMS4xODYgNS43NzM1LTExLjE4NiA5LjIyMTIgMCA4LjY0ODkgMjYuMTMyIDE1LjY2IDU4LjM3IDE1LjY2IDMyLjIzNiAwIDU4LjM3LTcuMDExMyA1OC4zNy0xNS42NiAwLTMuNDQ3OC00LjE1MTMtNi42MzUyLTExLjE4OC05LjIyMTIgMC4wNDYtMC4wMzAzIDAuMDk5LTAuMDYyNSAwLjE0NS0wLjA5MjggNy4xNDI1IDIuNjA4OSAxMS4zNjIgNS44MjkxIDExLjM2MiA5LjMxNCAwIDguNjk2Mi0yNi4yNzYgMTUuNzQ2LTU4LjY5IDE1Ljc0NiIgZmlsbD0iI2UwZGZkZCIvPgogICA8cGF0aCBpZD0icGF0aDQ5MDMiIGQ9Im0zNDUuNTggOTQwLjA1Yy0zMi4yMzggMC01OC4zNy03LjAxMTMtNTguMzctMTUuNjYgMC0zLjQ0NzggNC4xNS02LjYzNTIgMTEuMTg2LTkuMjIxMiAwLjA1IDAuMDMxMiAwLjA5NzUgMC4wNjExIDAuMTQ3NSAwLjA5MjQtNi45Mjg4IDIuNTY0OS0xMS4wMTQgNS43MTg4LTExLjAxNCA5LjEyODkgMCA4LjYwMTEgMjUuOTkgMTUuNTc1IDU4LjA1IDE1LjU3NSAzMi4wNTkgMCA1OC4wNDktNi45NzQxIDU4LjA0OS0xNS41NzUgMC0zLjQxMDEtNC4wODUtNi41NjQtMTEuMDEyLTkuMTI4OSAwLjA0OS0wLjAzMTIgMC4wOTYtMC4wNjExIDAuMTQ2My0wLjA5MjQgNy4wMzYyIDIuNTg2IDExLjE4OCA1Ljc3MzUgMTEuMTg4IDkuMjIxMiAwIDguNjQ4OS0yNi4xMzQgMTUuNjYtNTguMzcgMTUuNjYiIGZpbGw9IiNkZmRlZGMiLz4KICAgPHBhdGggaWQ9InBhdGg0OTA1IiBkPSJtMzQ1LjU4IDkzOS45NmMtMzIuMDYgMC01OC4wNS02Ljk3NDEtNTguMDUtMTUuNTc1IDAtMy40MTAxIDQuMDg1LTYuNTY0IDExLjAxNC05LjEyODkgMC4wNDg3IDAuMDMxMiAwLjA5NjIgMC4wNjE1IDAuMTQ2MjUgMC4wOTI4LTYuODIxMiAyLjU0MjUtMTAuODM5IDUuNjYzNi0xMC44MzkgOS4wMzYxIDAgOC41NTM4IDI1Ljg0NiAxNS40ODkgNTcuNzI5IDE1LjQ4OXM1Ny43MjgtNi45MzUgNTcuNzI4LTE1LjQ4OWMwLTMuMzcyNS00LjAxNjMtNi40OTM2LTEwLjgzOS05LjAzNjEgMC4wNS0wLjAzMTIgMC4wOTctMC4wNjE1IDAuMTQ3NS0wLjA5MjggNi45Mjc1IDIuNTY0OSAxMS4wMTIgNS43MTg4IDExLjAxMiA5LjEyODkgMCA4LjYwMTEtMjUuOTkgMTUuNTc1LTU4LjA0OSAxNS41NzUiIGZpbGw9IiNkZWRkZGIiLz4KICAgPHBhdGggaWQ9InBhdGg0OTA3IiBkPSJtMzQ1LjU4IDkzOS44N2MtMzEuODgyIDAtNTcuNzI5LTYuOTM1LTU3LjcyOS0xNS40ODkgMC0zLjM3MjUgNC4wMTc1LTYuNDkzNiAxMC44MzktOS4wMzYxIDAuMDUgMC4wMzEyIDAuMDk4NyAwLjA2MSAwLjE0NzUgMC4wOTIyLTYuNzE1IDIuNTIxNS0xMC42NjYgNS42MDg5LTEwLjY2NiA4Ljk0MzkgMCA4LjUwNjQgMjUuNzAyIDE1LjQwMiA1Ny40MDkgMTUuNDAyIDMxLjcwNSAwIDU3LjQwOC02Ljg5NiA1Ny40MDgtMTUuNDAyIDAtMy4zMzUtMy45NTEzLTYuNDIyNC0xMC42NjYtOC45NDM5IDAuMDQ5LTAuMDMxMiAwLjA5OS0wLjA2MSAwLjE0NzUtMC4wOTIyIDYuODIyNSAyLjU0MjUgMTAuODM5IDUuNjYzNiAxMC44MzkgOS4wMzYxIDAgOC41NTM4LTI1Ljg0NSAxNS40ODktNTcuNzI4IDE1LjQ4OSIgZmlsbD0iI2RkZGNkYSIvPgogICA8cGF0aCBpZD0icGF0aDQ5MDkiIGQ9Im0zNDUuNTggOTM5Ljc5Yy0zMS43MDYgMC01Ny40MDktNi44OTYtNTcuNDA5LTE1LjQwMiAwLTMuMzM1IDMuOTUxMi02LjQyMjQgMTAuNjY2LTguOTQzOSAwLjA1IDAuMDMwMyAwLjA5ODcgMC4wNjE1IDAuMTQ4NzUgMC4wOTI4LTYuNjEgMi40OTg2LTEwLjQ5NCA1LjU1MzgtMTAuNDk0IDguODUxMSAwIDguNDU5IDI1LjU1OSAxNS4zMTYgNTcuMDg4IDE1LjMxNiAzMS41MjggMCA1Ny4wODYtNi44NTc0IDU3LjA4Ni0xNS4zMTYgMC0zLjI5NzQtMy44ODM3LTYuMzUyNS0xMC40OTQtOC44NTExIDAuMDUtMC4wMzEyIDAuMDk5LTAuMDYyNSAwLjE0ODctMC4wOTI4IDYuNzE1IDIuNTIxNSAxMC42NjYgNS42MDg5IDEwLjY2NiA4Ljk0MzkgMCA4LjUwNjQtMjUuNzAyIDE1LjQwMi01Ny40MDggMTUuNDAyIiBmaWxsPSIjZGRkY2RhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNDkxMSIgZD0ibTM0NS41OCA5MzkuN2MtMzEuNTI5IDAtNTcuMDg4LTYuODU3NC01Ny4wODgtMTUuMzE2IDAtMy4yOTc0IDMuODgzOC02LjM1MjUgMTAuNDk0LTguODUxMSAwLjA1IDAuMDI5OSAwLjEwMjUgMC4wNjM1IDAuMTUyNSAwLjA5MzctNi41MDUgMi40NzYxLTEwLjMyNCA1LjQ5NDYtMTAuMzI0IDguNzUzNSAwIDguNDE1IDI1LjQxNCAxNS4yMzMgNTYuNzY4IDE1LjIzM3M1Ni43NjgtNi44MTc5IDU2Ljc2OC0xNS4yMzNjMC0zLjI1ODktMy44Mi02LjI3ODktMTAuMzI4LTguNzU0OSAwLjA1LTAuMDMxMiAwLjEtMC4wNjI1IDAuMTUtMC4wOTI0IDYuNjEgMi40OTg2IDEwLjQ5NCA1LjU1MzggMTAuNDk0IDguODUxMSAwIDguNDU5LTI1LjU1OSAxNS4zMTYtNTcuMDg2IDE1LjMxNiIgZmlsbD0iI2RjZGJkOSIvPgogICA8cGF0aCBpZD0icGF0aDQ5MTMiIGQ9Im0zNTIuMyA5MjQuMzljMCAwLjk5NzUtMy4wMTEyIDEuODA1MS02LjcyNjIgMS44MDUxLTMuNzE2MiAwLTYuNzI3NS0wLjgwNzYyLTYuNzI3NS0xLjgwNTEgMC0wLjk5NjEyIDMuMDExMi0xLjgwNTEgNi43Mjc1LTEuODA1MSAzLjcxNSAwIDYuNzI2MiAwLjgwOSA2LjcyNjIgMS44MDUxIiBmaWxsPSIjMTAwZjBkIi8+CiAgPC9nPgogIDxnIGlkPSJnNDk5NyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMTQuMDMyIDk0OC4xNCkiPgogICA8ZyBpZD0iZzQ5OTkiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDUwMDEtOCkiPgogICAgPHBhdGggaWQ9InBhdGg1MDEzIiBkPSJtMjg4MC41IDM3MzYuOWMwIDEwNS4yNi04Ni4xMiAxOTEuMzgtMTkxLjM4IDE5MS4zOGgtNzMuNDRjLTEwLjU1IDAtMjAuOTEtMC44Ni0zMS0yLjUydjE3NjMuOGMwIDMwLjQ2LTI0LjcgNTUuMTYtNTUuMTcgNTUuMTYtMzAuNDYgMC01NS4xNi0yNC43LTU1LjE2LTU1LjE2di0xODIzLjljLTMxLjA2LTM0LjA2LTUwLjA2LTc5LjI4LTUwLjA2LTEyOC43MnYxOTI1YzAgMTA1LjI2IDg2LjEyIDE5MS4zOCAxOTEuMzkgMTkxLjM4aDczLjQ0YzEwNS4yNiAwIDE5MS4zOC04Ni4xMiAxOTEuMzgtMTkxLjM4di0xOTI1IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTAwNSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzUwMTUiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IDE0LjAzMiA5NDguMTQpIj4KICAgPGcgaWQ9Imc1MDE3IiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1MDE5LTQpIj4KICAgIDxwYXRoIGlkPSJwYXRoNTAyOSIgZD0ibTI4ODAuNSAxNDAwLjdjLTcwLjA4IDI5Ljc2LTE0Ny4xNyA0Ni4yMi0yMjguMSA0Ni4yMi04MC45NCAwLTE1OC4wMy0xNi40Ni0yMjguMTEtNDYuMjJ2MjMzNi4yYzAgNDkuNDQgMTkgOTQuNjYgNTAuMDYgMTI4Ljcydi0yMjc4LjhjMC0zMC40NyAyNC43LTU1LjE2IDU1LjE2LTU1LjE2IDMwLjQ3IDAgNTUuMTcgMjQuNjkgNTUuMTcgNTUuMTZ2MjMzOC45YzEwLjA5IDEuNjYgMjAuNDUgMi41MiAzMSAyLjUyaDczLjQ0YzEwNS4yNiAwIDE5MS4zOC04Ni4xMiAxOTEuMzgtMTkxLjM4di0yMzM2LjIiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ1MDIzKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNTAzMSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMTQuMDMyIDk0OC4xNCkiPgogICA8ZyBpZD0iZzUwMzMiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDUwMzUtMikiPgogICAgPHBhdGggaWQ9InBhdGg1MDQ1IiBkPSJtMzIzNi41IDg2Mi43NWMwLTMyMi42NC0yNjEuNTQtNTg0LjE4LTU4NC4xNy01ODQuMTgtMzIyLjY0IDAtNTg0LjE4IDI2MS41NC01ODQuMTggNTg0LjE4IDAgMzIyLjYzIDI2MS41NCA1ODQuMTggNTg0LjE4IDU4NC4xOCAzMjIuNjMgMCA1ODQuMTctMjYxLjU1IDU4NC4xNy01ODQuMTgiIGZpbGw9InVybCgjcmFkaWFsR3JhZGllbnQ1MDcxLTYpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1MDQ3IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAxNC4wMzIgOTQ4LjE0KSI+CiAgIDxnIGlkPSJnNTA0OSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTA1MS05KSI+CiAgICA8cGF0aCBpZD0icGF0aDUwNjEiIGQ9Im0yOTk0LjkgMTE3MnYyLjAzLTEuMDEtMS4wMiIgZmlsbD0idXJsKCNyYWRpYWxHcmFkaWVudDUwNzEtNikiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzUwNjMiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IDE0LjAzMiA5NDguMTQpIj4KICAgPGcgaWQ9Imc1MDY1IiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1MDY3LTQpIj4KICAgIDxwYXRoIGlkPSJwYXRoNTA3NyIgZD0ibTI4NzIuOSAxMDA2LjljNzQuNTkgMzkuODMgMTIxLjk5IDk5LjQ5IDEyMS45OSAxNjYuMTZ2LTEuMDJjLTAuNDgtNjYuMjYtNDcuNzYtMTI1LjUxLTEyMS45OS0xNjUuMTRtMTIxLjk5IDE2Ni4xNmMwIDMxLjM5LTEwLjUgNjEuMjEtMjkuNCA4OC4xNSAxOC43LTI2LjY1IDI5LjE4LTU2LjEzIDI5LjQtODcuMTR2LTEuMDEiIGZpbGw9InVybCgjcmFkaWFsR3JhZGllbnQ1MDcxLTYpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1MDc5IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAxNC4wMzIgOTQ4LjE0KSI+CiAgIDxnIGlkPSJnNTA4MSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTA4My01KSI+CiAgICA8ZyBpZD0iZzUwODciIHRyYW5zZm9ybT0ibWF0cml4KDY5Ni4yLDAsMCw0NTMuMiwyMzAzLjksOTQ1LjkpIj4KICAgICA8aW1hZ2UgaWQ9ImltYWdlNTA4OSIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFKRUFBQUJlQ0FZQUFBQWpadlpDQUFBQUJITkNTVlFJQ0FnSWZBaGtpQUFBSGMxSlJFRlVlSnpOWGQyYTZ5aU1sTngrLzdmZHZabGp0QmNncVNRRWR0THA3dVdiT1ozWUlEQVVwUi9BNGYvOTczK0UvaUR4cCtSOFN0QWZKL25RS1B6RllKNlgvUHZsS3ArUCtzRVB1dVJQcHNEZnBDYXZ6cGpmNlp5elNmdlJDcDR3eFNwTDIvVEJld1QwRjRoN3JhWHZ0UEJKbVU4eFhaWE82MGRBSktQcnBIekNPMkM5QTVEN01uOUZXZnVXL1FSbzFvRGhVZmF6TnNCSG1jakJJZUZCWTVObFBLUXM3dGVkZEFlOHU0NzlLOVBwdTlCOXdpQXhDNmUvZUYvQ3ZVK3gwM25KZjk4V1lnTU1qVm9OMnZKNmRRTUJicktMeWg2a3Z6TEEzeHNvcHByQ2o4ZnlWOVZXMTc4THB2T1M2KzNDUUR6OWV6RlFqOEdFQU53T2VOdktYU1Y1QVBCUHBXK3pEeEdWclJ4anRSdjA3NERuM1hhZi85N3d6bmp4ZVE4bU5vMGNyaGVaK2VIVTRGRDd1a3l1b3N6NTduUjhRSEgzb3RXQ2ZOWUdLUUFtUlNWQ1hNcXMyaU9MejAvU2VlMWNJRWhNUkR4Y2JpSHZPNzJDQXlwUzJ6a2x1Qlo1dGM3NUtoWit4aW01MHlvd3Y1Mlc2dVIxdnF1QVVOczJjeE9lcXFsOHFZTXNqcDEvZnRaVFo2TUhUQ1QrOStEeFFhSWhUWlNaZ2NZMWJOVnJLbStWamhmTm9teEovTGFmOXFycjhuQmVXM29Pb01uRkdaK3lvYzJ4SFRjRDlOZzcwOEZYQytxd1N0dGs2bklJRWlhVnM1OXNUeTdid3owRkh6N2hieG5ZNzlnYnQ2Nzd3N284TDdKWXpDUlM4WHlmYm8yOGcyVkZjNURPZjYxbUlwM3RwcmFTMnNuUXc4R0pnZWFMME51b3huQVZtR1orM3doZWd1V1BJOXp2bWw2eUtWc3lrWDNTdmovU1BTbmEwNEtzOEhsOHFWaHlDallxZUM1UVlRRWdtTmV5U0ZBWkZtZ3N5blRiS0xja2NwbDlLNGhyeHlTaG5vZUQ5VWxpZWhVZjd3WU5jOC9XeG5PMnBYU2NvNTNUeE0wUVJNSUVwbFFGZ3Vsc1dGU0Vtc1ExSytiTVFuNnZRWUFJaFFidmpkMWdOTU04NVYxQktsd0RScHp1VFMyclV4bUt1aW56bmZSVTlpN2ZFK040ZVUyQnhCd041bFRPMWhjZ2t5UXF3UElONUJJUm5WZTd6TTFtRW1MdWlPUmg2MWhoOE1iNndFdFFRNEdoRnA2M2d4R0F5ekh6SXN4R0pETlVvb2Y0L3krOXpFeFRnZHBGZDlub1ZXRytZK1FSeTV6WnAycGpIOXMxZUxwS1pRT2xndlFVYW1Od2g3TW5FZ1pueFI1Ni8wZ0R5U1RSYU9QY3pKUnVhR2pMTWkrajUzZmg5cXFidjJXay9IMXJ1S050b3l3VEoyc2RhMUptS3VvWEI1YzBaQ3FtczhrL1UxbEVIUlFpRHA3SU5yTmJkS1ZMQjZlNUExOE84a1lld2J3Yk1qaURkWjZIRmNubFVFT1Y2bERuejZkOUFERzcxcmxzL3A0OHFxSmNnMERMenU5dWs4MDB5OFg2bkpsbTkzOHdrWXhnSWx2Rmh6TWhNYWZtVEZnU2tqR1NsOGdVbHlFYTRKeEZwTVo3UFRuL05rMFpLOFBwbDN6N2xQWUJ4SGtpaGJMRnRlZ2RWVDBkb3orcm1KTUJSTlJBQWFDa05vaDRQZEg5NzR2cGZWT2FzdEFSSDArajFHTGxtSGlJWVJoa2pUOXEwd3dLN0krYVl6dVpmdkVlVVdlNDFiaXY1dEFXSnNod1A0eW5wNTdobkMzN3RYdTU4ZXV4dk5lS2kyaFRCZEFBZVRnVFhjNUFTVlJyUkdlVHkrMGFRY041eEduUUdHYS9IL1dRLzZtQWhUeDJiSUF4QVdzeDhDc1Y5NVM1Zm5LRDFqdHBhd3ROQTc4Q1MxeElueG1vQU15aWpuajlzR3NJTVB2TEEwUkJFak1kSThpSHdvOHh5Z2NSdGVHNWRhYXFqZTZzd1MzaUxUbFNnUW5uUnd3MVBCbjRpdHlMaHYxKzJyVDlsU1dSYnNkY0lMSitxR0RMUEdBZnphZGdZVHFzWFMzdDhuQUdjcnljUWhjMVlXSVdBMFlBQmJDVXFpcFZaWjFvZW5YcVZEb3JzZW5QQTJRR3Rsb01yS1F5UFM4QXFweVRhNFlxYjRhTTM2U21oM3VmZDlZUkVZWURFUVFNZVNXQmJwYVkyU0xMMWI0VHBSRzdEM2FQSUZBWGNsbnpNcDBpRnhHd3lVRVM3UjN4MkUyUEo4bFFjVjVGQjVjUWovZ0JlbFVJUEcyRXFSK2hrcTM4d2FacEZJQlR4MkFYQXEzT0l1OFBxTGRxaTB0dFpHc1Raa3R2WnFtMWR6WXhTOG9UMUZRSUxzYmVFL2dlZ1JPdk4yakxLU1JFSWpiVEc3bG5sbzFuR2F0eENJU2VaMVFqc2JvSVJtK283UVNnTEI4ZkplZW5rRi9MYUp2ek5leUVYZnFVbHF2VXd5djVaNXNRbm0raGtpbzVSRHpaUTVsUllqUjZBTWtBRTZ4YUFKNDdXR0Y1Uk1qalJKMWQrbTFkTWtXMEhKU0JNejdMdklwUEJQRW11RGNQT29JbFJZQlNMS3JxdG9xQk10M205RnZiUXBhMnpxTENQajF6bytjblJEWFV5K0ZrakhHbnlEQ2pYUnNpMXVCTUx0L2dTNVRaamU2elNSdEJ3S0VaNFRtWXhFWmZPK1hMS21rZXdHTjhCR2VWaWNuZ0hwSGZ6N013MjJVNFA3Q05GU3VWVHd2cGlTRzc4aUJOeGtlUVZ4bTRzcERQUmE3NWU2WGVNZ3R4ZVc4c1k5QkZZTjFPb1FHaHZIamJpT2lnVStnL3VvVG9HUFpPRTRnRndTTjR4QUJORFRIR09raElFcGdJYkNkZjhmY01xdUtxTVF1THdPbGVhZmdYTWxibDkwa2VndVJWcVRmM054bml3QlcyVnM0SEJUeXVNM041QjA2bkVDOXkxV0NVV0krNllTS3RSNng5NXZ1b1RzekJhQkNMN3pkQ3pWb01MbEdVcFprNUE4N3l6bHlSR2FmQjUzbnI2NXhrYys4bjB3NDQxYjNNMmxWeTgrQUkxM3lzSWxqODNoaW54RVljZWxOWk1ObERHVUQyblZXZFhiYW1GZDM1d1NEb1NrRkN1K1p3djU0c01BNTJETnBTOXRDTHdjL0hqN0tNN09ST2FZdVVuN0tBVnJXdEc3TnpFcEc3SzdYVXk4OXUrQ1JuWXhzUkhXQW8reFNmQTQ4T3FHeVVhOTZUNkJwYlA4aWdheXY2cEp1VytoZTloamFQTjBLR1d1dUp3MExzMms2WlRtSkl3c0ZvV01VbVFqTm0wTjdLcWJhaFBwc3FHeWRjV2VCNHRuT1N6MW1vbUozc0RENTM2d2ZqV0lUYnAyY1pEcWl1Z1RmWGFEQlJtUHhqS3dqYVFjd3kyUy9NSGdyQXRhOEd3dlNqMlVOSnhSR0JYUVM2TUd6a1REMldnYkRxMEczMGVsUHVwOVBPM25xbS9qaTUyTmtWbnd2bDlTNzhncXhuK1NiMUJleERDRlpRWjBTdXRuRExhOCsybU4weWxqL0FOdXAyRVlOeHhDQ0pBckMwSU5wZE9rZXFDR3UxUlRjQ0txbmJEU1BsOUYxV2VnTElOUU90RGVWWUxtK004YzAwRVJ4Nk5TZVljdUx5ZE1rYzE4bXFkanNUNlNqMTFJVG9iSFFOdGNYRTNMMnZ3MkkvRW13bDFCL1Q0RnRmWEFZV0FTbTZIdWV0Uzg5WFBqZzhQckxlSzZOZXFkQWJsL203cWR6enZNeGJYQnNEbXZPc3JOTDZHZnRsTkpTSkhHQWVFOEpyWU9uS3JOWjBQQytJSndrSm5TVGRvdEVGVmlZaDRtaUR3SllURzgwSUhMK3VvQ3Z0bGZTQTZER0VEc2hBTlhVWmpiOXNIMkhaWGZvclZZYnBmdlZGRjM2RWRnd1J5MGZWdHBLdmNiNmVnTU1ENjNCUVhRZ29sa1o2WEZXSTZGUmhseEI5S1hoRUJ5T2hQWUhEQTRvS3JOaDh6SXUyZ00yeFFrV1pYSlVBTjRSaXVLc0NhazVWaC82MHEvOE9TQ2VRaEZaZW9JTFc1YXBsaVYyYnZEZTVCTXRjNkREMjBYcEVtRTRaSjJDWnhKWVhqa0UvT2tnV2VSNU1oY1kxSXpOcGZ2aWNCMW9QQWhBSkdPYldSOXJVTkd1aVBKUTFQYWVvakpncTF2cU5WTlcxQ2xMc041MWhlUzR6dWR6STJGa2Rac1BacjdsY3JNUHpYeVRrYTNQS1dDZHVTVlczL0pLT1VlWjVHMGRRVmFMcUM5bEJqTWxjSGVFZ1lrUDFTaTkvQkpVWXU3QmlqM29mVXcydTFZQjhjdWYxNDQzNWVmSXNVdVZBaU9BZWFoam9ORTNRMXBsa0JPa0VBVXBKb1BMVUNJSG00UUlob2JQSmYxT1FrWW02T3k1a3F5bk0ycmowaEtOVmF1TWdhMWxuWU14STBPU21vU0lWNXoxbDRLcTR5ZDRSc1MwWDNpd3A3UVdvTGpROFo2MjJjRlRwM1pjdnhOcHhJR1Y2d05WV2ozS0xTR0NkNk1uaGl4a3l3NGV0SDZuT1VzMEZGbU8zaWFJTFAyTTJmek1XWW5maVp6dHB0cDJ3Q3hSMHNRclh0YmpJbW14NmIwT1JudGhKdTdUYjgvT2c5UGR5eWMxOXluR21QQ1Y0dW9mTFdUT0FFaXRWZGhITmF0THJFdDBLY3JucUdDTWcxRldMQ21xRGxiNVVlT0JabWRXSEpQYkJKOUJtY3phUlk1YzBHTXhwWjRFM3RmVHMzb1hCcStEYjFmT0lpVkttT0gxamF4dzhGZHNsUm9QeU13UFZyRlF0WjF4d0grdlVpTFdDN1JTNmlGVWRzYXEwTmxqRzBSelpSWTF1OGZzVVZVNitwdmx4OEd2Tk1aTjFrOXFqSzFubmJ2UTJTUGtPQjFYVnZFSnFVOVp4WWIxU2VQL3FQUWxzVk9ldGdWdGJpcmd4emVvWG9sUGtIelZtT3FodmdjaXM1RlFucm1hd2hzUVNBZ3lteXlmOVhCc0hRQkZSZU1FVjdQNjFzdGlRZkI1Tzg3d3k4SzhPN0hmVFhWWDVQclp0M2t2TktaK2FCU3ZHMGU4KzNXZVFIU0UveWxEbXd5MkhHRUxvQzhDc1ROU3o5VVZZUHljcTBCSzBUL1NGRDdNTkJDRUJlUFlPbk1SQWVqOHhGTkdLS1B6cCt6WW9aOENwVENIZ096YlNkMUxsWFlVazVjZFVPdWFvRjFudlhQcHNPK205UHVwWlpvTzFNcWFvU21PZGZkUlBFVmpGSjlqQnlDMEk2ZTU5TjZGeHUrb1hLOU5BSElmVGl4a0FJZWp5dXhIcjdGUFpPams4NEtjd2s0dElSRWV4YVd0RnpuYi9tNmM5NGlETjlhOWMvN2oxanhKVmVBOU9qSld5VjJxb1loaTluaGRVRlVndDFDVUwrd21QRTZGTnBLckdtdExNRGdrMmpReHh3ejNycm5oNkFZUStISHRYNEJQSWFMcktEUjJHTXlBWGhNVDVNdVR0VEJXVEw1dlU2VTdGNFVMd0s4bEx4QmFWZHM2R2xkYm55SlRIVzdqSGZKZ1d5RldzbjlWSFh3dkc0MFhLTzBNWGdKeStuMGowVUNMUlFiNlc1dnVNT0hoYlBNYmJ6cFdGQ1NTQlNVekZzUUkxTHFVRVZXZVB3Y1p1SmdOUTFma3dXZGd3Wi9JZzZWYmJWZi9kcWJzNytOemRYLzhtUjFEK1ExWmtwL2pPb0Y2bTJLa1YyVVV1aWd1c2N4bk1IN2JRWnBVb3V0cXYzNVhCbUVUNmFKNGlEUVpTLzdJeFRGWXZ2cUtmUWdBMDVwczRrK1FUVGZwdlJUS1JmZHFVTjljL3lVbmJQR05Yck5NbjdLWHNhenl2VGFoYXJTZWFHYVBpNHBnSEJwcUlxZ1ZXM0V3VzE3OXlQUnpVWUFUUUFKVTRRL2ExTTBHbXdPaXlSNi90dXJpWEZZMXJJN29KZEVxakRQTHhGQzFEZmRCSDArQUV1VW5CN0FMTm56bWQ4VE5KSlB0aHN4R2NQOGRCVDFOVjF0Y1JGTGJaSiszSnppQlRkakwyQ2UzczN2alpxRHQ2NW1XSldnQnBDY09NWWhlbHJCVmV2cEFHTThlRFZCWHRvdGdZWXlMYXNRL1dNelBRSDcxTlpwbDg4Ty9aY3JKbmltd091S1BJQStxcnFpSmNDMmR4Q01NR0dMTHBjblJoekZjNmVweUl4bDdabnNmRU5ISEdPTVFIbWJnTFBJWlJoL0dhSmpLZjJ3STVtZW0wc2NIR0pnU29zaGprNWRnSFM2d3NHT2pwK3RpN2FiVjBzaUxFSFdEaVVaOFpHSkdwZVBwY2VXZStDcS81THhMUnQzOUVWWVZiWnJXOHFzVTJaSjFxSG4rcElTM1MzWFp5VmpoTTFjWEhybC9hUmhFYzdKY2l1MHdtcE9lelJkVmtYSVA0YjdubE1uMzRVUG8rT09lM3Z1Sy9tbWVlUWpZaXhZcThsL015UWJZUWpiY3BnRXc4ckJFWktoOHJPbFU3MnRJQ1N6amhnYW9PRzJPTUJLMVRockJqMkVUMFZVQ05RYVkyTklKRWJsVVJCaTJqYlFTemxXYVFyajcxZHIwR2d2M0xBT08xZVVDWGlubGZaMkNwMklxZHJYUk41UXJHQXdiQytycnlZbE50SFM5czNydWQ5c2lkcng4YVM5aWFRV0JrSTVqWXZvOU80MnhnWjQvUGVSWlZWR0FiVUg3VGdpNmtZR0VFd00rcWN3Zk9UL0tTOXhkV1dIbFZzN3FKY3B3ZGF0Yyt5NEk2UnA5WDhuQ1JLUWN0Y1lHVmFENUZhM1VQZWJZOWxrU29jYmQ5RkJqOU9oc2dlamIzcnJLMmRrWlJSS2pIMW8vYlpuTlNLQzVoeExQN2tVZWdGL3d6L21BTnRtV0hoazlyc0x0cWx2WE5EQlhmdWx2eHJFMi9rZC92VksvVlVFTzRXUitybXNvcTB0VmIzaTNRTlEyVE9rUk4raWQ4eWNNcDhzKzNaVWhYUlYrRFFnd3N1cXdCWU5LVi80NGNmL2pBYU1CZWh2M3hqeDk4Qk44Z1VXNkFrTGpzeUZZZzI5cEFMNmQzclprVlJyWTRubTZ1M3Y0eHE3MUtiZ1JpbHNmbHBJcExHaGp4OXVDaUgzVGt3RllYMkx4RTQvMUUyUVpSdDU5SEk2eXBBMWpSMFBZQUpUNlViUUUzTnNKNzF1S0NmYlRPQlJNRitlL1F5bHJtL2RWWG9QWmUyK1lsRGxSaHlQZkpmbEd6Z2VLMldYdXpuUUhFeDdQblJaWUNRUmJwMXZwd29rS0FWR2dzd0xMMGhVdDJXMlhhOWpFczdYVGtyYXMvWUJxTk05bURqdnY1MEs0dVJiQW9BTkdZaWRvNGVtVmtZQTQzVXo2L2xtd0NlcFpXYkhhM3pyWktxMFhZMHJiaGVXWGRaU0RUajFWNEdPRFYrNmxqRURMNnZPcXUyOGlKNW10bUc3bDhJb3ZyU1ZlVnRqMjJzZERYMEEvYTJMN3RnNjBWcVBZOEdDbmpPNEdCcnV2VHZnY2FIK3dyNlNsbHNwVlhGcGhuTTRnQjc1dHJUOUorbSt4emFiTDlkRmNvTXROczc3Z3E2aVBHZEF6dmVxNER2ZVFEeXZ1QnJ5YmUwMFM2Tjh5bjRTWE9oQjE0WFo2L2dsaklER3NpVlROTVRab0ZEOXVRY1F6UjBXTlRjMGNvYkFVWk13TnRwVXVJdnJReFZsZnNIVk5vN1BtaUtSZzdFK3Q0bFN6ZXRZYzA3ZXJiM3J0OTF4QjRadkNnZlRwbHRkZnpOWXIyWWUyTnBRMXRnZlhHcjdvQUF6V0pMT2pNRllLTnZoa2tXaUtoT2IyWjBqcmRlaE5JS2ZXQXJBb08xN0g2bktvS1ViUDZneWFpbmRocFB5anJGemxZOVVXUUVnZmtLYUFxS2JFdGUwblRpMDJYY3ZFZUx0WXF6SHh3d3lwYzhZTTZEaGFmL2NZNmJzVVNDNDVtbEJQVzRBWlRuWjJhMmpqSncrWTJrNERqcFRiSWlOVWNNTERLTmt3eXZmVkQyYy9OWkZkOXhQN1F4NWdaREFhN2h4SThDcnNiRm54eGFiaWVTNVdqOUlLcWVaaHlWR3NWNWJyenZseTFESk81NktPRzd6S1NYRFp1alVYN3htMmZDRFMzaDNSeU05enpkdXNTeVNuMmN1MyttcjBBcE5GY1haM1hONlFKVWRqY2p5amxGTHZwUDIzVWdrbW8zcGwrOTlmUkNIWkhLVE4zOXQwclpMS0MrQlJJbnFjN2JxdkJIM0lBT25TUTQrdGw4SVhsOHdhYy90c2N0WnBEdVZoS3Yrc2JQWTBSckt3dWt3aWRYVjBNaGxCQVFOaTNiMEJ6TjEwSDFVRkNJMjhMMzUxOVdtaGdkT2xqZ3dPNzBRM3pKRkRnSnJaK244UDlPVW1vODdzSjdaTGJ2S0h0a1dWWFhwem54NFMyMG1wTENGRS8vdXpUTTc0SkpMNU1xTi92YmVqYlpkMSswckxtNFEwaUdkdGpuUXI5dEVlRFVSVjdTc0JqVjIzSjA0cVorZ2Q4dkF3WTdRQjlKRk54aEY1YjZEWXZNcVdvbHZhS0lyWm5JM1NUbnZCYWJXSDZ2WFRWdXJCcWkxQThJOCtFZmVmWGN3MU1SUDlvdU42cC92emF2clJUUzIyUHdFWUt3dUdkaVc2UFpjQUtqOHdpcEQ5VDFjTUE4QXBpZHV2Y1hzQVF2RFJuTEFVRE5wMUlZS0RUSG1IUFJHNUFocWRLN0lZZTNQL2ZWTGR2WHQvUzY3Rk1wT2xZQW8vK3hITFIzb2tiNzExbGVaa20zYkM1ckU1a09yZVZORm80dkxNK3pDTCthaGxWUm5HN2hxczh0R2xXMFd1Tkg4VnRzazdhRWJEMldKQXZkaFBLblFLSWdpQ0xaZTdTSjdjV3ZST0lySXFzWWtNOUlWTW9FMTFFQVNCUk9mcUNhalFCM0JzWDIrNmhmWHlRcnZ6SExZamVKbmQ0VHBGT2M4eTZvYXd2bGhJcktrZGpCbGo4aDRZVmFyanhua2VJdmJreExMRjYzTTlkMlVQYVJQeEZ4L0FyaldyWWt6OTAxZFV4WWJjbEpTZHpIc3Vid2dHMTI3NnpZZVJSUHN4ckFJQ2liZk9NV1IyN0oxV1hRWTlMUXFCQlI2UlJRMEJDd0JGWCtSdW90ck5udllDQmZGVS9QdmZVSEZOYmJoY0o2YThPQ1NLSEFNL2lIcDJ1REN2TGpZeXBQaDVyZWZHeVA3aXJYSytyc0RXS3A3amJQelF6UzgwYi91a1I5NVg1L05jTjQ3Tm9QRnB5QjAyMmpWK012MHlFKzZteFRGei9pZ0poMGpHNXVoVmtPVjhXT1lYK2tSRFJsekFKREliYk5WNlJyYndQMVllTjZGNlhQM0pPQVZNeUFBVEdqN0dMRU9GYjFmVlRLNENSVi8vZHU1dFZYcFZlQWRSYXhtczZySTQwRSttcm9HT3FBVWNwZnFNVEV1OUZhN05xZzU4ZjQ1Q3ZadXJNMk01RFBIN2JnMGRuS0NHb2UwL2UwV1lIOGZqWkJnUE9HSDV4MHRQeTJrRGNCY2tFZGhPd1hkeDduUjVIVmF4OWxkQkJDQ1pVYjZzQnJuNjc0dzF6WnBsV3AwdVFJYkZ0ZWFDbmZVWGdrdWNVbHk3Y1UwTmxqK3FwNTlWcGx0U1pxRnQvaklrZTkwcm9aLzN0MTk1MjF1MnhqcXZ1LzRzYnpFWWhRNFNReFkxSWRBQXZBNVF2cE1aNTd2R2xaRFlLQW9qcjdhMnA5ekx6aFBQNVZ1UGFDc25lMEhjMzdrL3l0blpTVFBtMWRsTXVtYTZNdkptWloyL081YzF4ZklGeitHSkhWbFVEcFY0MGRjY1dKZmZSYnQzRnY2VHZQdVJ4dFBWTDNmMVEzazlxMjRhMDhRVTlNWUduUnRkYkYyenQwWTJCS0F3L21tSjV5MnhPSGF3aFNHQS9FYkZqbHNxNysyVGFCUXcxNVhQNEVnWXV5eU83aDBkNENLNXJ2aGhrVERaTUtJdGJZSDFyQjliWnZicjQ1aEFpUEMzUzZCS2kwMGZOYmU4MmdFU0l6ekQ3UnhXTVJ1ODBWMGkzVmRKUVo0SE9FYVNValdITTQxc3pnMng3TUNOajY0N2pobG4yQUh1VzlyamIzOFVYZU0yQVMrQ0M3NjdTMGJoRmhZUEpEWWVvNnFJUkhxL3IvaVFQQ1JoakVSSFJNUUNrZThlNkJIL2RIb3NGSFUyY3VPUG5kU3NneU95Z1JrUmY3TWp1KzRpd21jWkZzZWxEYkRqblB5VEc2cEt6UEkxMGpFUzFoOVJ5OEp6dk82UzBQbk5mSjJmZ1BUUEc4MlJFOGYyNk5BVVJnM1JwVkJuemFKVDdWbys4S1kxc1RQdGVWN2VoakkzNks0aHhtNlQrK2hXWmVsSThUbmFNYlQ0ei9pSWl4MXArTzRjeHhrQ1BrTnNpQ2hSdFlBU1ZBak4zQkc0UndhNVRacW9IdEFMT3B4SWE3Q3RBVGE0NjNwUDVPaktOcTVYTVpCS3ZRNzhTbEd4RHZLc3hOTHAxaDVEK0g2WXRLZGxnNi9WWFljNitkdEtDcTZjN0Q4VllCemVnaWMxMk5rTTR2UU43UEVFREpSWGV3RWJtTnhpUjZvUDRFV3ZYODM1ZUlUNFc1b3RySm5NWGFOcWZ5MzhWWU0rWXA1UnEvYWtETHV1OGhFRkV6U2VVZjg3S09GdWlIR1VxRFBQRzNZK3FSVngxZVJtMWpmeDVtOFR5cDI0andDMGRydFpNUHhFYXpMaFVFUlpYN2I1K1YzYUtkR3pxVFNqWVdObzVBU0RHV3JGYmNWWUdReGsrTHFQWkwybWROWE1zUWJlOFhOeVkxRXVSWlhUVUREUTlKcXIzd01PMnNuMGNsSjhkWVRCUWxuU1BFTnBhaDAxUVhTdUxiZVBPUkNoVEFVUzZVVzNjaTF0WWg4MHk4cHMyNWVHeWlzQzdyLzFoeUs3NUdsaU9sb1pUc2FqbWRpU1JFT2JObG5ROXNpRzBiaUgzV2JZNnN4YlplVnZqK3dNYmJscFlEVlNyKzRYY21LaEVhaHpJWjNrTE5CRHRINzEyK1JTWGZpLy9PdmJwSVNYUWcrUEFvb05uM3JicTM2YW1oczg2QTF6TElWWEVkNUQ1M2lOZHpwaVhia3MrQUpuMXl4N3lETTdnV3JEQUM5cHQrcEc3VkVQWlV4djUyRmExZTVCdCtoMTlnK3NWOGtUMWhoS3I2VlgxVmhGWE1nSnp4YWo1N2Rlb2lhWHZyVFZqdWpkQmY4SktGMk9aeEg0d1dOOEtPLzR6MVVORWNKcVdpYmdGTU1USGFYWVY4MWhFbXhJN1VaMHlZeUhvUGJtOCtlcm5rb1BqQVF2dDFKakp3eUgzWTBJRWZkZnpBWU9FOXZUODhZMW9pUTZNaGFpVENOeHJkbkpXN2RnK0lIYmFJOW9qQXpnMFhvWStnb1dzNE9CcVRhcS9tMGdZejV1TkVEckRDeUUwdTUwRVlhSnhicW92b1BZY2VhNTRrTkw5dHd5QzJscUpqTmp6WmEvbFoxUE5UbG4xenBZU2dqQnUzNmltaHU2RjFnQ0t4bkFpQjhjM29rSHNSN1FVdG12b3AvQ0drYjcwcEd1WXpueEVwd3BBZTBITHhnQzNYbU5LcUJnRExjNUl3QW9HSkcrZjBwYi9MMUFHdXFwNnA1SGFTbm9mdS9SdTBmWHBZdW1kM2YwZUJ1ZFNtWVVRM0ZMa1czbHdQazYrZ3lLdXpmRTBjUVRreFd2WUxwY1Mvb1oyc200RklmQ1VlcWJHYktjNmdEdjhvU1NlK01CN1FST3JMSnhSTnVwdUUxWHoxS1BheWxJMW1JYW9hVkNlck9TLzVLaHR5andCVm1sa0Y0TmI1US9QU2xRTXJyTnM1S2tLUUNBWC9rWUE2UlJmOXlKRXJEMHpTVjk0ZFkzTDhYalFHRzJHL1BIbzBEem9XdTZpYmxzZEFKb3VMd0lKRzV4WnB3SlR6cWQ1YzJmbGhNSE5UNmU3dGJNZGNITDVGVHZoOXdwQUZYaFdlU2RHelBXS0w5TnJmZ1RZS2VPSVVQUys1c1l5Q0hTMXBRMEJMeXNOdWxidEsrNjhCUklWOWMrR3NGKzRBOVJLenBORjB1K21aOTZYZnEvYkF6dEJiSkxWcWk0Q0tKZU5XMFpvQWdSKzNnR1hKTmRQcnM2bUJMYVJWaEE4b0FKSStaZEtzMXJEUWN4cXkvY3dSVmFxYktCS1BqNVVUbUVDL0VGNlVpMjREVjZ1R015S2ZWeEd2OTlYdVRqZEc4dllZTklHZHRtd2x0V2IyRW0vbnYybXZ1eTgxNEEyaktvMFlsdVVXTnBHZUQ4RFF5dG05cGx5R1B5aS9WT3B0enNiYUdmL3ZJS2RwL3owS1R6dWpPdGN6d3BBbVlHeW5EbUNYVjNMQzY1Y3lsVndlaHQ0Z0lqbXdSSm1pQnZsQnRYa0d4WlJVNWtNSkM4RCtTU0RveDZxVHhuVnVmNVhVcVh5bjZUWGpldTY3TXJRenZjcUFQVXllVm5kNjF1ellkMWJweWdMRGRzb013RlJaS05zR3hFNVA4WmRQNzNGYU9SV2pEVDlXSGV5cWZ5QjllRmpoMVUyVUFXcVZmb0xEVmVyanZYM2VpdEhUTlZtL0JXQTVxV08yaFlLQnJYNE5TRWVjU2VPTmxHMlE3UXkzeWh3RDZReUV2d0NrTFQ0eXBBdWplaVlkV3RZVjdKL0t0M1pYOVh0R1Voelk5R215ZXBtVldmbGlWVjloKzFRQUZWdHlkcnI3SHVsWnpiU2x1SzdHUlZJRnNsZU5GWVhXU3NWTmhubzFPTklSSENPTTZtcTZrR3FTTyt1WS83S3VONVZWd05wamZLS2hXN2pRSW1aWnNhWmJhR3BqUnVWRnBnbzM5aE5XT2NOZ2N6RHFPYklaSVJBVWdFOEJ5U1JsYko5Tm9wWXF1eWRYWHQvaVh5V2RlK0FQVStFZVN1SGZZWjhLL2FSQktxS2daUWpWc1owVm1QNngvTzVLaHRiUVdaVkpoUlo1Nm1ScWd4bUlFaU1sUE5tSUJGRlZxckFCSUw5YThGS2Z1OXY3SjZjOW95ME43WXI4T1E4bFp3TUlHU2hWK0prQ3N3WWNQUy9IckZXVlNaK2JFZkF4RlpQRFcyam1ZMzZ0Y3hJUk0vWklJT0ppSUx4L2NRejg3eFBWOHQrSnoxUlZaNDNwanU3cDBxckNaUkJWYkhRVGg2eWtCRFIvd0V3WWxkZjl6LzJkUUFBQUFCSlJVNUVya0pnZ2c9PSIgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsLTEsMCwxKSIgaGVpZ2h0PSIxIiB3aWR0aD0iMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSIvPgogICAgPC9nPgogICA8L2c+CiAgPC9nPgogIDxwYXRoIGlkPSJwYXRoNTA5MSIgZD0ibTM1MC4xNyAyMDAuOTdoLTkuMThjLTIxLjczOSAwLTM5LjQyNCAxNy42ODUtMzkuNDI0IDM5LjQyMnY1MjMuMDdjLTI3LjM0IDE1LjY0NC00NC41MDkgNDQuNzc1LTQ0LjUwOSA3Ni44MjYgMCA0OC44MTEgMzkuNzExIDg4LjUyMiA4OC41MjIgODguNTIyIDQ4LjgxIDAgODguNTIxLTM5LjcxMiA4OC41MjEtODguNTIyIDAtMzIuMDUyLTE3LjE2OS02MS4xODItNDQuNTA5LTc2LjgyNnYtNTIzLjA3YzAtMjEuNzM4LTE3LjY4NS0zOS40MjItMzkuNDIzLTM5LjQyMm0wIDE1LjVjMTMuMTU4IDAgMjMuOTIzIDEwLjc2NSAyMy45MjMgMjMuOTIydjUzMi42NWMyNi4xNjEgMTEuMTA2IDQ0LjUwOSAzNy4wMzEgNDQuNTA5IDY3LjI0MiAwIDQwLjMzLTMyLjY5MiA3My4wMjItNzMuMDIxIDczLjAyMi00MC4zMyAwLTczLjAyMi0zMi42OTItNzMuMDIyLTczLjAyMiAwLTMwLjIxMSAxOC4zNDktNTYuMTM2IDQ0LjUwOS02Ny4yNDJ2LTUzMi42NWMwLTEzLjE1OCAxMC43NjUtMjMuOTIyIDIzLjkyNC0yMy45MjJoOS4xOCIgZmlsbD0iIzEwMGYwZCIvPgogIDxnIGlkPSJnNTA5MyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMTQuMDMyIDk0OC4xNCkiPgogICA8ZyBpZD0iZzUwOTUiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDUwOTctNikiPgogICAgPHBhdGggaWQ9InBhdGg1MTExIiBkPSJtMjY4OS4xIDU5NDEuOWgtNzMuNDRjLTE1NC4zNyAwLTI3OS45Ni0xMjUuNTktMjc5Ljk2LTI3OS45NnYtNDIwNS41Yy0yMTguMTEtMTE2LjIyLTM1Ni4wNy0zNDMuMjItMzU2LjA3LTU5My43IDAtMzcwLjk2IDMwMS43OS02NzIuNzUgNjcyLjc1LTY3Mi43NSAzNzAuOTUgMCA2NzIuNzQgMzAxLjc5IDY3Mi43NCA2NzIuNzUgMCAyNTAuNDgtMTM3Ljk1IDQ3Ny40OC0zNTYuMDcgNTkzLjd2NDIwNS41YzAgMTU0LjM3LTEyNS41OSAyNzkuOTYtMjc5Ljk1IDI3OS45Nm0wLTg4LjU4YzEwNS4yNiAwIDE5MS4zOC04Ni4xMiAxOTEuMzgtMTkxLjM4di00MjYxLjJjMjA5LjI5LTg4Ljg1IDM1Ni4wNy0yOTYuMjUgMzU2LjA3LTUzNy45NCAwLTMyMi42My0yNjEuNTQtNTg0LjE4LTU4NC4xNy01ODQuMTgtMzIyLjY0IDAtNTg0LjE4IDI2MS41NS01ODQuMTggNTg0LjE4IDAgMjQxLjY5IDE0Ni43OSA0NDkuMDkgMzU2LjA3IDUzNy45NHY0MjYxLjJjMCAxMDUuMjYgODYuMTIgMTkxLjM4IDE5MS4zOSAxOTEuMzhoNzMuNDQiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ1MTAxKSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNTExMyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMTQuMDMyIDk0OC4xNCkiPgogICA8ZyBpZD0iZzUxMTUiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDUxMTctMCkiPgogICAgPHBhdGggaWQ9InBhdGg1MTI5IiBkPSJtMjQ3NC4zIDM4NjUuNnYxODIzLjljMCAzMC40NiAyNC43IDU1LjE2IDU1LjE2IDU1LjE2IDMwLjQ3IDAgNTUuMTctMjQuNyA1NS4xNy01NS4xNnYtMTc2My44Yy00My4zMy03LjE0LTgxLjg5LTI4Ljk2LTExMC4zMy02MC4xNCIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudDUxMjEpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1MTMxIiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAxNC4wMzIgOTQ4LjE0KSI+CiAgIDxnIGlkPSJnNTEzMyIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTEzNS03KSI+CiAgICA8cGF0aCBpZD0icGF0aDUxNDUiIGQ9Im0yNTI5LjUgMTUzMS43Yy0zMC40NiAwLTU1LjE2IDI0LjY5LTU1LjE2IDU1LjE2djIyNzguOGMyOC40NCAzMS4xOCA2NyA1MyAxMTAuMzMgNjAuMTR2LTIzMzguOWMwLTMwLjQ3LTI0LjctNTUuMTYtNTUuMTctNTUuMTYiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ1MTM5KSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNjEwNSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgMTQuMDMyIDk0OC4xNCkiPgogICA8ZyBpZD0iZzYxMDciIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDYxMDktOSkiPgogICAgPGcgaWQ9Imc2MTEzIiB0cmFuc2Zvcm09Im1hdHJpeCg5MTguMiwwLDAsMjUzLjIsMjE5Mi45LDYyLjkpIj4KICAgICA8aW1hZ2UgaWQ9ImltYWdlNjExNSIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFYNEFBQUJwQ0FZQUFBQTlma2hyQUFBQUJITkNTVlFJQ0FnSWZBaGtpQUFBSUFCSlJFRlVlSnp0blh2d0xWbFYzNys3ei95YmtNR0JZUmdId2tzR1poeFVOQ0dPb0lRU2Rhd2FTbFRFcWxGSFVTbUloQUJURXRSWWxCS2lwYUZDYVVKRlV4Z2ZWV0pSeG9JcXFBaUpLRVJOQ0NOUERUQno3M0R2OWM2ZCs1N0xuWEdHdVo3dS9MRjc3MTU3N2JYMm83dlA0M2R2cjZwZm5UNjkxMXA3ZDUveis2eTFWKy91WTdxdTY5REwxWS83QjlpMDNIUG9NSXd4TUtZQmdIN2JvT2xmVGRQNGZVNzh2bjdiMjVIM0RkRy8zT1hHWDd3VlprV09sMjdTN1lhZGs2Q05OVFd5UCs1VHM1ZVV0SThrc3FXU2Fpdnd2UXNaL29NeTBpWjhLRzJpYjJFbnQ0OVUyUHV1N2NpMnJodm9kWXJPdXNQZGIvd1FzWWtQcGxOT1Vpc2RTL0crdGtDblM3OW5ZKzNBOWZVKzZibmh2cmtmZXBLcHo1YjQrUHF2ZXg0MktlY3ZYUFRicG0zYjZHdzkvaC85dzQwTzRON0Q5eTNnbjBsS2d3Q1FnWHVqNkFtNnNtOWxnQlVCSWV1TFM2bmVwaVVCZENvYTNJRkU4Q2lBdktpV0FIM2tJeFVVQ21FZjJpL2dyd0gvcG9FUEFPY2UvSEx3UGdBLy9SQ3V1ZnB4R3gzSW9mdU8yQUVzNEo5TlVrRUFtR2syVU9BN3RKWDNwK2hmK2xFV0I0Z05TZ3JtZ1Y1dVZxQW9GRUVlcUFNOTA0OEJwdWl0Ty96Vm16NU05TFF4TCtBdkJmL3picmtsR3ZPY2N1YjhoZUM5NDZUNSsvVTZPbHZ1WkR6aHE2N2U2S0FBNFBDWGppN2czNEJFUVFDWVpUWWc2aXQyVWo4cG4xbWphYXF6UzNHSko2TmNWZVlCc3BBWGZaWm05VXlYd3o0YzN3Sit6WGNPL0pzR1BnQ2NQbnNld01CSUtoNzg0b2NGNElsYmdQOTlSNDR0NE4rZzNQaUx0d0pBOFd3QXlKZDdwZ1FEcWIvWVY3cTl5dG1tcElMODQ4bzhtcTlNTmkvWWxtYjFBUEJYYjhobjlhbTJCZnhwOE4veXRadUgvcW16NTdWL1BjdlRTNWN1QlNPTWduOS9NTmRlOC9nTkRDK1VvOGVPTCtEZnNJaEJBSmdjQ0t5T3RFOGpmMktRUXY5SjNSMlVmRXBMUEVCSm1VZnJvNnkrTDlrblFjOXN1bldIVDk3MVArUXhMT0JYKzZ3Ri96YUFEd0FuejV3TCtBbkUvMjdtMHFWTDhYZEMrWkMyQWY5angwOHM0TitpekJZSUJCdXJwL2V0QmdYRlYwcTIrZkZYbFhnQUZleStXUUM4M1YvbVN3d1F1WXU5Q2RpSGZoYndhMzNXZ1ArV20yK094amEzbkR4elR0elBnd0FBbU1mNmpELzFBUVBEQ1hyU0U2K1pPcjRpT1g3aXBOOWV3TDk1Y2YrQXozM2J0d0NvRHdSQWZYYWZ5OVNUZ2FHaW45bWxFdndhMkcxYlhUOWpJQStVZ3o3MnZZQmY2N01FL05zQVBnQThjT29NQUxtZXo4VVlBL09WeHg1VFA5bjRwQXh5M1JZQ2dJUC9BdjdOaS9RUENKUUhBaUIxSVhka3VXZEVDYWM2V0ZSS0N1S3lmazZocnAraUZUMzllbzFQL2ZTZkVIOFZ0YW1ndndYOFdwODU4RzhMK2ljYzlJVTJMUkJFNEUvQm5wKzBKMS83aFBwUmpwRDdUNTVld0w5aDBjRFB4UVVDb0N3WUFDTUNRb0ZQVlgyRDlmNmF1djVnbFBOWkFYakZYN2Z1QXNqTC9TemdGOTl2QVB4ZmUvTk4wVGcySWZlZlBCM3RDMHJrdkkwRUFmUG9vNDlHWjh2dEVFOGtPMUhYWDNkdHhWQ255UU9ueml6ZzM1Q1VnbCtTWkRBQWt2RE9yKzRaOGJsdTRxc3c0dlNVekE2cVYvU3NPM3o2elI4Ui9KU1ZhbXRsQWIvZXB3VCtiVUdmbHNLZDhPdytHUVFjK0d0Z3ovVzZyc01OMTE5WE91Wko0aTVnTE9DZlY2YUFYNUpzTVBDTmFUOWpQOVk1U3o2MTVSMXZsek5MdEx0eXpXZDZ5R2RkTGVEZktmaHZ2dW01VWQrYmtyKzkvd0cvTFYyNEJmSkJ3RHp5NktOZDdtVDQvUXoyWFA4cE4xeGZOdklaeEsxVFhjQS9qOHdOL3BRVUJ3V3ZOTDZ2cVYrTDBhZWx3STdEZllxN0JmeTdBZjgyZ1gvMDJIRy9IWlJ0M04yNEZVSEEvTjBqai9oREs4bnN1UjV0YjdzT1QzdnFEV1ZITVpPNFc1SVg4RStUYllJL0p6UXdBSVhCSVNWanpDZWVqbzdkRVAvWnQveVpyRmNJNUFYOCt3ZittNTd6bktpL1RZbTd5ZFVKWCs3TzkrZUNnSG40NFlmakduOG1zK2V3NTdwZDErRVpUM3RxeWZITUltZk9YMWpBUDFIMkNmdzFrTHJwN1M4cTFpMEpJQnpZbW1nZ3I1VUYvQWNQL05zRS9xSDdqb1NRNzdkelFVQ3lvZUxCUHdmc2gzM1c1cGxQZjFyQm9jMG4vQWwwaTVUTFFRVi9rYitwOWhzOE53djREdzc0bjN2anN5UC9tNVI3RDk4SEFQNUp4blo3bmlCZ0hucm9vUmo4UWlsSGdqM2REbXBmWFJmb2ZNMHpuNUU5eURsbENRRDFzb0EvWWIrQXY2cnRjZ1QvdHFIL3hYc1BBVUQ4RkFNaENOQjJLUWlJcGFDTEZ5OWE4Q2ZxOW54ZkNleDVJTG54MlYrVFBkZzVaWUYvblN6Z1Q5Z3Y0SzlxdTV6QS81eG5ieGY0bi8vQ0YwVlF1MjBwQ0dobG5VWUlESDRXOE9VTEY2S012N1NVdzlzNDdPbEo3THJ0MThlQUpRQ1V5Z0wraFAwQy9xcTJ5d0g4MndZK0FQek41NzhRL21ZR3pDeEJRSndGWEhqd1FYOG1wbWIzL25vQW5BMENXN2Z1ZFp0TG9Kd3NBU0F0Qy9nVDlndjRxOW9PTXZpM1haa0FnTTkrN3EvN1I5S0hJQzhKQW1OTFFlYjgrZlArVE15ZDNRL3RRNkR3ZWx0OFRDbVZKUURJc29BL1liK0F2NnJ0SUlKL0Y4RC96R2MvNDU4eFloQkMyZ1dCSVpOSHFLZGN1QjFnbjc0Z2JNNmRPeWVBZjF4MnI4SGU2N0Zmb2RuVzcwMUtzZ1NBVUJid0ord1g4RmUxSFNUd1AvdFp6eFRIdFduNTVLYytqY2JEdmQ5cEdnOTJ1My9lV1VDdzc5eTVjMTBPK0tYWnZTdmxTTm05MDIzYnNDK24rL3h2K0FieEJHMWFsZ0JnWlFGL3duNEJmMVhiUVFEL3JvRC9pYnZ2RnNIY05DYTh5NXdFZ1pKWlFPb21MbkVXY1BiczJhNFcrRkx0dmlTN2wvcHA2VzkvQXZpbjMvU053dW5hdkZ6cEFXQUJmOEorQVg5VjI3NkRmMWZRLy9qLy9ZVGZ0cUNYQTREZGRqdkxad0UxWlNCejVzenBqb00rQmZ6U2NvN2JUWUZQL1hQZ2QxMXJnMEhiNFp2LzJRdVVVN2Q1dVZJRHdBTCtoUDBDL3FxMmZRVC90dThsb3ZJWGYvbS9ZVHpvWFUzZnlnRDZ1RDRmekFLRWF3RlRBb0E1ZmZwVUJINWdHdkNsY283VDkyMjh2ZC9ma24wdnZQVlcvV3h1V0s2MEFMQ0FQMkcvZ0wrcWJaL0EvNnhuUEYzc2R4dnlzVC8vY3dBV3RvMERkd0Q2Sm5pTVZHb1drQ29EbFY0SENQeWVPbld5R3dQODB2cDlLcnQzUGdFTGZEK09ickQ5MWhlOU1EcWgyNVFySlFBczRFL1lMK0N2YXRzSDhPOFMrQUR3WngvOVdBaHhFd1lBQU1Fc1lKTUJRSm9CbUpNbkgraUE4VFg4MllEdis2VFhHQVovTC82MmJ4TlA4TGJrY2c4QUMvZ1Q5Z3Y0cTlwMkNmNWRBLzhqZi9xbkFJYU0zbTM3MXowSkFPYUJFL2QzcVZVNmN3S2YvbkJCRHZpMC9OUzFMZHErL2FYZi91M1MrZDZhWEs0QllBRi93bjRCZjFYYkxzQy83UWRDY3ZuUWh6L2NBejBzcmRBQVFQZk5IZ0FxcndHWUV5ZU8yNHlmQWQvdUkyV2R6RVZicllaZm0rRnJ3S2U2WGRmaHU3N3pPMU9mdzFia2Nnb0NDL2dUOWd2NHE5cTJDZjVkQXg4QS92c2YvekVBQ3ZydEJBRHRJckMyQ2lpQS8vMzNIKytTZGZ6RXNreHBsVTdxb3UwWTRIUG91ekVDd0cyM2ZaZjJXV3hWTG9jQXNJQS9ZYitBdjZwdDArRGY1bTk5cE9RREgvZ2dBMnU0UWljWEFPWXFBZVdXZ1VyM0Faamp4NC8xTmY1OFdTZTFEci96KzFzWitMMlNWc1BQWmZodWpHNi84OXQxSFY1MisrM3BUMmhMY3BBRHdBTCtoUDBDL3FxMlRZRi9YNEQvdnZlL1B3QnZZNXJKQVVDN0NLeXRBbkx3RC9zckwvK1k0OGVQZGJWWmZxcXNvOVh4cVIyb1BnRSs5VjhDZkI0Z1h2NDkzNVAvMUxZa0J5MElMT0JQMkMvZ3IycWJHL3hQLzhkUFNRMXhhL0xmL3VpUFJLZ0hvR1lCZ091N0FDQ1ZiSGo1QjBBUUFPWW8vM2k5WTBlUCtMTThlNWFmS09zTVVBL0xPbEdBNklOU3krMm9mdHNOZ1FNZFh2RjkzNWYrQkxjb0J5VUFMT0JQMkMvZ3IycWJBL3o3QW5zQStJUDN2dGZDRlE3Y01kQnBBSWhMSzZFT2dDZ0FsTlQvWjgzK2p4MDkwbWxyOGt0cStWT3ovRlFkM3dHZnRuSGcyejQ3ZElnRHh3Lyt3QThVZmJEYmtuME9BZ3Y0RS9ZTCtLdmFwb0QvYVUrOW9XSjBtNVhmZjg5N0FBSGFQQURJbVgxWS9xRnRVdlkvdE1mWnYzcy9KdnZYbG42YW8wZS8xSldVZG5KTE5ITzEvS2xaZmhRZytpeWZBdCsxb2V1d1hnKzJQL3hEZHhSKzFOdVJmUXdBQy9nVDlndjRxOXBxd2I5UHNBZUEzL25kMy9NQVhhMzZUSnhCMjhFZlFCUUFTc28vSmRsLzZ1SnZPT3VJcy85czZlZm9rY1AyNG03L3VhU2dYMUxhQ1FDTUVQb2xGMjlMc254YTFnbDhFT0JMOWo5NjU0OVVmUUcySWZzU0JCYndKK3dYOEZlMWxZRC9xVS81NmxIajJiVDgxOS8rSFRHRFg2MmFBUDYrYlVUMm43cjRXMUw3ejYzOEtWbjNiNDRlT2R5NUxCOUFFdnE1ZGZrMXBaMG9RQ2haUGgxTFRaYlAyMm5mUC9HcUg2djlQbXhGZGhrRUZ2QW43QmZ3VjdWcDROOVgyQVBBZjNuM2I0VVp1UENYeS82MXJMNjI5Qk8yVFlPL3M3V3ZBL3pOa1M4ZDd1YUdmbXFaWm0xcGgvWXAxdko3NEFmOWtUOXhodEdQNXpXdi9za1JYNUh0eUxhRHdBTCtoUDBDL3FvMjJ0ZFRicmgrVkwvYmtIZjk1OTlRWUdzaStOTjJudjA3K0FNUXMvK2EwczlRUHBKTFB4VCt0ais5N3ArRS8zMkhEL1dsbnZtZ0g5YmlkZWlubG1uV2xuYWs4azdVYi8rRnRQdGI3L3QxLytLMVk3ODdXNUZ0QklFRi9BbjdCZnhWYlRkY2Y5Mm92cllsdi9ZZi94UEwwQnVoemo3QTMrb0kyWDlGNllmcVNLV2ZtcnIvSFBBMzl4MCsxTzA3OUR2RVVKZEtPOEV4Q05CdjZUamJRYWR0VzdSZGl6ZSsvdlhqdjAxYmtrMEZnUVg4Q2ZzRi9ObTJmWWM5QUx6am5lOUVZeG8wWkNYTkFHa1ovbDVISy8xSXdXRlA0Ui9VL0E4ZnVxZlArTWRkeUExS05wbWEvaWFoVDhHdlFkOEIzeDVIQ0gzdm8rMXcxNXZlT1BhN3RYV1pLeEFzNEUvWUwrQVg1YXVmL0tSUlByY3R2L3J2M3dFUStEbjRBdzdDT3Z3RG5TM0RmeE0xZjkvLzRVUDNkQlNjcVhYNlkxYnZjREFEK1pwK1RYbG5idWl2TzZEdC8wbmU4dE4zVGZpNmJWK21CSUVGL0FuN0Jmd0FEZzdvbmZ5N1gvNFZ3QmcwVFlPVlFRUi9BRDc3M3piOGEycit0YXQ5VXIvMjVYMVQ4SSs5T1d2c2trME4zcmtMdVZKcFoyN29vKysvN2Z2N056LzdscW5mdzYxTFRTQll3Sit3djRMQmYvMTExNDZ5MjZXODdlMi9CQUJvVnYyakNtYUF2MnNINUZVNzBnVmZRTC9aaTQ2QmdsL3lQMldkdjFydlAzVHZGN3NwSlo3YXVqNkZ2cmRSU2p3YytnREViRDhGZlQ5R29hYlBvUS8wNEdmUTd6cGJjT3JhRm0vOStaK2I4SlhjcmFRQ3dRTCtoUDBWQlA2RENIb0FlT3N2dk0zQ0VXVDkvSW84cGlBQi81cWFQODM2Z1JEUzJtcWYwc2M4ekZIdkx5NzVTT0FmZTFmdVhDV2Vtcm8raFg1Z3c3SjlGZnIyQUlkc1B3RjkraUM1dG0zeGIzL2hyUk8rcXJzWEdnZ1c4Q2ZzTDJQd0gxVFFPL25abjM4cnk5aGorRHZ3QThqQ240SWZrSUNiS2ZrQVdmalBVZklwZmJhUEJINEFNUGZlODRXdStIRU1NMmI3WXNhL29SSVBCN1phNG1IUTkvMEF3YkcxN1dEZnRpMSsrZTF2bS9UbDNSYzVjLzdDcm9jQVlBRy9xSmRyTHdEL1FZZThremYvek05NVVKcytrNDh5WThqdzUrQUhCbGlXbG54OFA0VWxId2QrSVAxNEJ3Mzh3eGhuelBvcCtPZXE3WE00QTV2UDl1VUFORitKaDBQZjlkVzJyWDNNQlRyODZpKzlmZElYZWg5bEY4RmdBYitnbDJ0blkzenl0VThZT2FMOWxidis5YzhBNk92bWpSSGhMNEVmS0t2M2MvQUQrVFgrYzJiOUh2S1ZXVDhORHFWWi8xVzVreTJ0NUNrUkR2MGE0ZEN2c2hNdTZPWkV5L1p6ZlZIb3Qrc1diN2pyemQ3Zk85L3hLOFhqM21lNTV1ckhSZnYyWldhd2lKWHJubmpOcm9ld01YbmRHKzVDMDhPNlhiZStiaTlKMTNWb0FUUnRDelNOWjRjRFkwcmFyZ1ZhV3dxeVBoQmNjRTMxU1dXOWJnZjRWOWkyYU5HZ0tScnJZTnZDbUFaZDF4WGJBWGJsWmhiODJtRDVMOXlYMkVXMTlVS1JidFFxRlo3dDE0eVhaL3RGL2ZYNlAvV3YzdVJuQisvNnRmOVFOZVo5RnlrWUFFdEEyTFJjem9Ebjh0cC8rUWEvMWw2VHRtMjlEdDB1a1hVSHJOb09uVS9RYTRBTEd4d3E3THg5MjFuREViWnQxL21nVnQxdjF3SWs2NytxcmN6R1EyZjFJSjdUZHN3RlNWcm1xUlZhMjYrUjEvelU2N0h1YlgvelhiOWUzZTlCRVMwZ0FFdFFLSlVuWFVGdzUvS1RyMzBkVmszakw4Uks0ckoreTc5SytLR0Q2ZXFEQk5DWDVFWUFGK2lEaGJISWFjYmFvejVRQUVPZzRiYlZHYi9zZkh4TmRtcjlkSXI5ZWtMWDdjaGpmdFdyWDR1dTdmRHUzM3pYK000UG9LU0NncFBMUFRoY2U4M2pkejJFdlpNZis0blh3UFQxK3BRNFdJK0JINEJzbVNocDIzV2pnQTJndWd3enQ3MG1zNEQvU3BVcHM2VTdYL1ZxQVBhRC9lMTMvOFpjUXpyUVVoSWN1SncrZTM0REk4bkxBdkh4Y3NlZFA0N1ZCSWpYeXFiZ2VaQmxBZjhleUIxMy9yaS9Edkg3di90YnV4N09nWkluZk5YVnV4N0NJZ1h5Z3ovMG93QlFYV0taUTNZSi9YME5PSlBCYjR3Qm1tWjB1Y2NZTTZsY004VitaY2FYZSt3WHVCMlY5WnZHb0VFamxvdGVlY2VkYU5kcmRGMkg5NzduOThZTmJwRkY5a0MrLzVWMzlPdm9Wd0F3cVZRenhSYkE2RElQTU53Yk1GYW1qSHRxNE5CV0psM1ZOR1ljdkl3RjN4am8wb09aWXQrZzdvN1R4alJvMFQrQzJocFg5ZGswQTZ5cnhtME1WazJEZFYrbkxMMCs4UDJ2dkFQcnRnVzZGdXUydy92KzhBL0srMXhra1MzTDdTOS9oVjNMM2hqQTZLQjBaWjZtYVlxV1RGSng2L0diZ3VzQ1hLSjEvSlhDYjZxcTdadmVIekNtMzdIWEdmaWR2a0JseHU4T3VCYldwakYyalN3c2VHdnRWNnZHLzhwV1RmK21NV2phZnVsVkJYRDlCOXQxUU5PZ0lZOXJ5TmsxVFFPMGJkQm5icnc4S0RTcmxjLzZxZHorOGxkNGZ4OTQzeDhXSGNzaWkyeFN2dnRsM3h2Y1FPV2c3K0crV21WQk9kZ09kNzJXUWwyNmdhdFloQWUybFp1YTBZRmdVZ0JoVCtxc3N1MWZremR3bWNhZ2F6dWZKWGZvaW0rQm5KTFIyNmc4dk8rc2svSStIYWpSNU84MWFBeFdyWVY3MjdZd25jMG8yblhhenM4NGZMbW53STVuL2YwTkcxcGdjUEMzTnNDcWFiRnVoMkR5M1MvN1huK1BRZHUyK05BSDM1OCsxa1VXbVVGZWV0dnRhSnJ3YnRZQStvcFF1Tk5zUHdWNEIzSVBkWkx0cDBSOVpFTkd0RWMybElqMHlJWWFPK2twblhuYitKRU5wWElWWUNOQTIzYldycUtFNDhvOXNFNTY0T1poTDU2VXh0NHRsOHFzSlRzM0FxMVBudldqeFZEdVVXd0NpTk9zSC9yTXdjQjR1NUtzbjhPZlovMVVYNE8vR3hlYUJpKzk3ZmJocHlYYkZ2L3pReDlVeitNaWk5VElTMTU2V3cvcVJvVytGeUhiNTlEbkltWDdGUHFTcEI3WG9Bb3Q4MVJrKzZtZlpFemFPUnZoQ1ozRnRwVmxIdWx4RFpKY1ZYSnhsR2I5TG5LV0JZWkJOeWozSkRMeHhoaGJlbWRaUHkvM1JEYkJqbnpXNzhmV0lNcjZPMkZxWXhUNFN4ZDUxWklQaHJLT3Z5bURYT2hObFh4SzRPK09vWUg5WjNXUHNGaTNMVDc2a1ErcjUzeVJSYWk4K0NYZjRXRUhvQWo2V29rbktPUDBRck45RGZwVWVMYlBvYzhsKzRBMlFWSVBhTk9FdzdrMDI0K2hYbmJoT3ZXY25uUi84V09abzFJUHpmclI1Sit6bzJYOVFDS2pKaGRad3dhNVAyTk1FdjVTUHdPbzdWUWl5dnBwUU1pVmZMb2hGTGdnSThNL3RNdkIzKzByZ1QrUXp2eFhHTzR1ZExlVDA3WVh2K1E3Yk1EcGc4Ny8rdWlmaUovTklsZVdmTXVML3JtSGgybDZjQWZsbU9GbkN0WHlUZytoa2t5L0ZQcGFpWWRDM3duTjlqWG9CMUx3Y0RZcVVyYWZxOU5yMlg3U0p2Rnd0cFM0TWs5cHRnLzBHVDlnRTBacGRZOVU2ODlsL1ZFR2pqRHI1M2JCQUgzSkJ6N3JUK29QWmtHZnpzYU9SWUUvSy9uWUthQU9mN2RzZFU3NHIxYXIvdUZTY3RuSEhXL1hkV0xOMzl1ejdML3QrK2phRm0xdnZ3S3dCb0MyeFF1LzlTWCsrVW50Mm42dS8rY3ZQaVorbm90Y1B2S0NiMzRSbWxYRG5nRWZBaC9RczN6Zlp1UUx1YzZuVnRNUDdlWHlEdFZKUWIvb09meTlsRHlSMDBucGo3QU0rdm5ITVhQaDJUNkhmcVN2WlBzTytwcG92N3ViL0xIMXVYNlFoZm9IMG85bzlyb0Z6K2FuZnFYSE5OT3grREZtSHRNTUlQdWpMUFlZOU9mekQrZHdzRzBGL1g1UXZ2VFR0djM0eUpMUm9LMkh2N09sU3owOS9QMzVHeTc4MHRvL0xmOTBSSjhHQUsvYnR2akV4LzlTLzFZdHN0ZnlUUys0RlVBUHlQNGFsQVo4dDQ4REg4Q29MRDlvWTFtK2E1T2dIMlQ5bWZKT2FhWS81eTl2QVhGR3JrRS85QnYvVGZuWlJhcGI4aWhtZXg0S3dBOWs0RThlMDF3Qy8wZzM4M3grcHpNWC9PM1lDNTdSYnhVbi9UZ0xnT2lSelJUTVVRQWdnSGUyTGdCMFVoc0pBR3Qzb1prRmdPRWM1Z09BMDNPZkJ3QWZCRnJhM3RyMlQ5NzljU3l5UC9MMXovOG5GaUxOQUdvSGV3QWUrQjVjQlJtKzA0dUFieHZFNVpwU2xtL1Y5ZElPb0svZXNUNWo2Tk1NUGZlakt3Q0tNdjA1b1E5Z0k4L2Z0enBsUDdUdWRLVnNId0RNZlljUGRlNEM2Rmo0MDFMTW5EL0Y2SFRtZ0wvMWxmNkJGdXBIZ2orQTVJKzBBQkN6ZjI3THMzL1hMOC8rL2ZGUENBRCttRWdBOE9lQ0JRaHRGbUMvQjNJUW9HTUJnTTk4Nm00c3NobTU1ZXVlSHdCVGdqMkFaSGJ2ZEVNd2h5VWRaNU1EZnF5WHFlVmJBekhMOTc2RUxKLzN3N044YnV1Zzc0RHZ6NEVBL1pxZldhUitwa0ovK0Z6eTBCL2E4OUNudWlyMFRRTno1RXVIdTdhMU1BVGtrby9kUHgvOFhUL0E5TElQa1A4QmRucGNZMzZXRWRCTFArNGNsZjVLRjUwNWxKWi8zSGtxQ1FCQVdBS2lZK0N6SDIwVzRIendJQUJBTEFmNThRbUJnQWFMdi83Y3A3R0lMamZkL0R3QURPSUVwRUZXNStBdlpQYk9Ub0s5dDJuMGk3Yk9QbFhTOFg0bUFKLzY0R3YwUjJYNWZUK3BKWnU1MVRzYTlEbjRwNVozM09mSSt4d0xmZis5S0lBK0FKaWpSdzVieG0wUi9yUVBRSVkvMWRIZzczUm85aThGanJGMWYrK2ZaZi8ybk1nLzBXalBTM241eCs4ZkdRRDhNVkxZSm1ZQnJyK1NJQUFNTXdHMzdYMGtaZ051bkY2UHptcUFTSmUzZi83L2ZRNlhtOXo0bkpzRE9BSUk0QTBnYWk4QnZkTWJZSkxQN0syTmlZT0JrdDNidmxhaEwxTlEwckdLQWJ4ZDM2VmxIZGRYN1U4cWd1alZsSGFDL29TL2twOVdwSDFMMEsrcDZWdWRlYUR2ZkRXTnNlQUhMTTlTOEFmbXEvbFQveEw4ZVR1SHZ4M0wrTktQYjYvSi91M0Jpclgvb0QrQUJaYnhBY0NmU3dueXlrVmc1OGZOQW1nN09wZk41NE1BM0hHd2NwRzFsd01CRU04SW5KNzdqQU5kSlNCUW0yZy9TUzZrRytuY3ZudnYrWHpVTmxhZSthd2JQWVNvOEgzMEg0ekRQZGlYQUR5MW9hVWJJTTdvblc0cXEzZmJ2Rzd2ZFl5NzJEcXMwSEgrdGV6ZXR6ZjZTcDFBSjVIaDIzM2x3Qi9HVmxiTEQvcGtXVDQ5SDFLVzcvcVNzdnlnTGJGa1U0Tys1SC9zaGR5aFBaL3AyLzBZd0EvTUFQOWVJUVFzeStJVGRmK09saWJHbEg3Nmc2ak4vdTB4aEFFZ2hISFp5aDk2enNZRUFLb24yYm5aUm1vV1FOdGRIMklRc0laUk9XZzR4L2xBNFBhdE9lQ0ZZR0MvR3VtQUVOZ29zT2R0MUE4WEtURFVpbmlEVUNZSVNBRUF5QU1leUVNZWdBcDZ0NDluOWM1SFZNYXhCbG5ZdXo2MTdONlBTUUczVnNPbmZZd0J2clBud0hmbnVxYVc3L2NMZndCR2wzYmNXRGoweFJtR0FIMnFrMXE5WTc4Ny9mZE1nSDdqKzdZNlZ4bXo2cC9EMDhJWWE5UzI5bzdYNGFDTkR3Q20vN0xRRzd3YTB3Q21Ed0JzbmIrN3djdVkvZzdoSnJ6Skt4RDMvVy9oSCtqbURqcUFPMW5yYjR4Qlo3b0F1TzVHTDJybmJOMTZmOS9XdjdxYnZjd0tNQ1FBdURYLy92ajZ2bFp0Wi92cEE4QnFaVHpBdmUvK2k5dTFyZi9TVU1EYUw3dDk3ODhaT3BpMVFVUEE3YzlkLzM1bGpBMENxNVdGZk5QQnRMMTkyd1g2NjM1N3RWcjVJTENDS3djMVdLMkdhd0pZTmNOc29OZHZRQUxCYWhVRkFxeFc5dDRCaE1FQUlET0RKaDBRM0hHNzQvUDdnbTBHZitHTzdGTFE4MEJDUVowU01ldG50L3NIMDNnaDJ3ZEN1SE1mdFpDbjcxT2d0NzdpbXIwZFJ6NnpkOGVtd1o3YVN0bTlPMjc2VkUwSy9BQytNd1BmK2lndjY5Q3hjZUQ3TmlITHAzWlNscy9iS2ZERHRyaTBNNHcxaGo3OTltclFENzdpcHJGMzdob1lkS1pKd3Q4NlJRUi8wd2NGN1NhdjNySi9iYjJqcG9laFZJSnhOM3F0ek1wbi8xUWM3STNQL2hHY25LN3JQQnpkbmI0MENCZzNrNkg3amVuOXhRSEE2YmdickVvQ0FFQldBUGtwdTRVWkI3a0xBUDQ5Qmg4dUNOQmdRWU5IU1JCdzUzVk50dWwrczE0RFdQVUJJZzRFVG84R0FpQzhwdUhHVHZmbkFnS3VpbWNKMWg4cDg3QTd0ZDE1OGUwSzdLZjhIQ2dWN1paOWNTWkFieGdpejMrWHdFNTlyeGpJcVY0cDVBTmRFMmIwcm84VTZDTWRvWXpqeHlyQTNvMUpXNGRQZGFWQVljai9TS0JUVU5KeDU3Z0UrQ0QrUzhvNmZEOEZmdFJHZ08vYlJtVDVWTCsybmsrL0R4SDB6UkNBemQ4ZU85cjVmOHcrODNjeXBmVGo5S1hTRC9WVmV1SFgrZVVsSEtwWGV2RTM2TDhMYS90OFhHN01xUktRNzRkZUEraFBZS29NWkk4cHJ1blQ5OXB5VUdycnhoT1V1OWcxQVgrTzJIV0JTTWY1NDZ1RXJJUCt2VndlQWprdU56NDNOdHFtbFhHbzdacnE4TklPeTlodFgyV2cxNTZnV3ZwREhkSUR2ZmlNZ1FjTCtwd2FEbXNnZlQyQTFxS3BYUzZiRDNRWjZGMWJLcXZuT2xOaFQvdVJzbnZYSG1UM2ZiOCt1N2RHRVdDM0NYeDNqampzL2ZGVjFQTEQvWFZaUHBBdTdRejczY2tkenFreEJ1YjQ4V01kaFNzUUJnQU9mN3N2REFBTy9yNU51UEJMN1lDeTJyK3pzZTBPMHZyRlg2ZFhFZ0FrSDFvQXNNY3lYQU1ZM3V2M0FBQklyZ1R5K3NnSEFTQzhIZ0JBdkNqc2JQaUZZU0N1Ky90enhjQXQ2bFVHQXljbFFTRWNUd2psVkoxZjBuZXlWdmFQbFZVakJ3VnBOc0NEUUNOQW4rNFBIaFdRQ2dvVThGWTVHQnVGdkhzZjIwcmxJbG1Qd28vWDNwMjlnelNBV1dEdi9HalovVEFHL2E3Ym9BOHB3eDRCL0xBUDJVZHBXY2Q5em5ObCtZRWQ5TkpPQVAybUI3OXJwd0dnTlBzSEVBVUFDbitnYnRtbjFaZWhUc2ZJcyswNUF3QWZnOWRwaDFWQVFSOEZzd0RYN21ZQkFOVDdBV2ovems0S0F1NXo0aGVHblQzZnh3TkIxTjRPL2wzYm1zTmZDQWIwUGZYTmc0TGtoOXRHOEdmQWwwbzdwV1dkM0RVQXFYd2pTZTdpTGhBQ25kdElZS2Y3T2R6dFBoM3cxRThBYjhUWlBOZWxHWDJrTDREZTloMnZ5SEg2MnF4QWduM2dpOWhwU3pLcFgyMVpKclVKK2xlQXZRM2dCK2VnQVBoV3Y3NldQK3gzanNuTUNRakdhVTZjT043ellQam56MlgvZHJ1dS9BTnNMZ0RRY2VRQ0FOWDFFR1NyZ0lMakdqa0xvTWZyeDEwUUJIeGYvamoxYkg2V1FPQStXT2l6Z3VDOUVEaG9lN0N2RHdxU1hlL2NiMHJCd1lrSWUyRUdNTGlkTitNSFpOZ0RjWVlQeEVHQTZnUS9WaUtBbmVvN3VBZjdFb0FQMmdYSWUzMENMNjZqZ1I3SVovWGNSMGxtNzN4SnNBY3dPYnVuL3FoT2JtbW1PNGRqZ1IrTUl4cGZlVm5INld0bEhkZXUxZko5UHczcis4U0o0eDFnLy9lRGNnN1AvdTFPT04yUzhvL2RYMTcvRC9RVEFjRGJrM0ZLQVNCNG43b0hvRDhvYlJiZzNtdDNBZzlqekFjQlAzWWhDTGl4OFdzQzdueW1aZ1AwblBCQUFNU2xJZWZISDFzaUdIRGY3cnpUOTFTSDIyaDZkbHh4Y0hBU2xXeVVPajROR3Bxa0FnVVZDZVNTaUw4MkpkVC9KYUE3U1lHZDdoT0RRZ25nN1E3WmpvQTAyc2N5ZW1lbmdkNzU0UmRvdlc2bWpPUDB4c0RMNUx6R0FBQVBlVWxFUVZTZTJqYnN2WmpkOStjazBzbGN0UFhITWpMRHQzclRnVS9QZVdsWlp4aG40M1hOQXlmdXQrQ0hnMEFtQUNUS1AzYmY3Z0tBZTUrN0E5ajdLcHdGY0I5OExMNDlFd1FBdlJ3RVFMd3dETVN6QVhkZXRVQVFIQS9acDgwS0FoMGhpTkQzV2tBSWROdDRIOWRkYTRHZzVFSXVDUlpjU2dGZks2bUFRQUd1NmFkbkFUcllnUmp1Vks4RzhPTCtWWmdkOHRLTmU1MENldGN2djBEcjlWa1paL0Fmdzk3WlJKQlZNbndnbmQySGZjaytwRlU2WEdjTzRBODZaWFY4dSswR2t5anJFT0M3ejhXY1BQbUFCWDg3M0hFSmhBR0FRaFhJQjRBZ1M4NEVBS0NzQk9UODVlNEFkdVBWWmdHaEwza1c0SStoRzVhRFRnMENibHhTbHIySlFFRGJ0Rm1CZlkxbkJxS2VZay8zdWFBQUZHVDlmRjErQnZqYUJkdFMwSmV1OFMrdDh3TnlNSkF1QktlQ0FBVkpMdHVuLzcybGdBL2FsRXllNmxDLzNHY1I2UHR4bG1iMTNvZnZJNS9aK3pZQjFHNWJxOTI3Y2RaazkyNWNVai9oMlBNMWZPcDdPRjQ1dzQvN213SDQ3cHlmT25YU2d0OW56dk1GQUtkTEE0QzF5d2NBYWd1UU1aQlpnRlIvRDQ4bFB3dncyek1IZ1VoUFdCN0s5Y1VWUWxZSlFCd0kzRmc1ckxzdXZFYmcrcUU2MFlxYnlvQVE2QWFCT2hFWXlMaUJHT1ljNHZLRlhCMzBjOXlwbTVKVVVKQit1U21WNVFONXFITWZITVMwWGN2Z3JjNDB5RHRkc1E4V2FGS2dsL3JpeXk5ZDN5cGNDeko3cVMwRis3Q2ZmSGJ2M3VjZXB1WmZEYUx6TUtha1k3ZmRnTkxBcDdvVStGNzM5T2xUWFNmODQyNDdBRkMvcVVkQWdOaHdxSmJNQXFoL1RiOGtDQUFvdWlaQTIveTJNaHNZOXNtQndMK1NRT0QwM0luWFFFMW5CdTZjdWY1b2V5NGcyRzA1S0ZEOXlFYjRqa2w2WWdhdlpQV2x5elpMWndWT3BFeGVFbTJaSndUN1pOWlBRWjZCUGxBSGQ3cXR6UTYwVEY3c2kvaWlwUnRuTXhmb3ZYNEMrTUE0MkV1NnFkbzkzUzdPN3ZzVFNvT0p0UXR0dGcxOGIzUG16R21mOGRjR0FOdk9yZ0hZblY1WFd3VUVBTG43QUlDNERCU01UWmdGV0J1aDlGSVpCT2hZZUJDSWZHWm1BMjY3SkJDNDQ5SUNnUnR6TklaRU1LQ2Z6ZFNBSU5ueTlqQzRFeDBsUUhCNzJqOFhEZUtienZhZEpMTitBZmg4V1dkdUZrQnZKRXVCbmRwS013UnBkaUJsOFlGdXJtUmpIVWEyRlBMRFBsNGlhZUpBSTJUUTB2Nm9oTk9QTFlJNHE5bHJ2bkt3bDNYMTdEN29SOG51cVEzOXhJdFc2VmdubzBvNjBqRUJnRGw3OW13UGZnRW1DQU1BTVB3anB3S0ExeU1CQU1oZkI3QjIraXpBMnltekFEcitLVUVnOE1PQ2dQZUoyRjl1TnNDM3RVRGd6amNOQkc2c0dwQnJad2IrTStvbEZ4RGNtT2k0cVI3MXorRmNFaUQ0ZVBpNHVKUm04V01EUXZHYWZtVjJJTjBKYkJEcU5nMS9MMmY0S2JBRFpYQ24yNmtzSHFpSFBCMERyOUc3TWNYWnJBNTZ2ay9LNnYyMmNJRlcxTnN3N1BuNUdjNkZuTjNIWTJ4OEc4M3VyUjA3TnlVMWZQYTU4OEJpenAwNzEya0FsVjZCY0JZd0pMMERuTFFBNFBTMU1oQVF6Z0tjWDNVVzBDdEtRVUF2cmFTRGdOc3ZCWUZvdTNBMkFLUUR3ZENuZk02MVdRRWRjMjVtUUY5NVFLQTJKVUVoOERtNFVPL016V1gzcVVDUnNndmJ5Z0xCSE1LQkhiYmxMKzd5ZmFsWmdBUjFBRUVJRVROL0JlNjB2eFRnN2U1eGtIZTZzYjk4UmcvVWdUN25MMWV6NTl2RnNMY25WNFc5MVowL3UvZCsvUkRpOHk2T0ZVTndNY2JBbkQ5L3ZoUC9vUlV3YXdIQXRrMmJCZEMrcEZtQWI2OE1BdFN2dEFSU1A3NzBiQ0RhVGdRQy8wb0NnZGEzTml1SWRBcUNBYlhoQVVIeVd4SVUzUEg1ZHFXRUUzNzJvZVR1ME9YMlhFb3krZEpaZ1NaYU5zOUZteDJVbEg3NGpXRVMwUG0yVmc3aW1Uc1F3NTM2cWdHODNhOUQzcjFLa09kdFd1bEcxUk51cXBKMGVWWlB4NjdwNjljWjZtQlA3U1RZaDhjOUxyc25weWxaenBINjRtM213b01QZGh3UWRGc0xBSUdPVUFhaXR1TEZZTnZnOWZtMUFOcmZsQ0FnSFVzSTYzaDFrSGlzYkRZZzZjZSt3MENnMmFBTHJ4Rm9yeVhCd0IxVHFDY0hCR3BURkJTc2d0OFVnME4vUEg2VElUOTYybVpodHA5QytDYnUxRTJKZGhldmI1ZjJaYkorSUFGMHF6em9WWUxkTmplUjdSVEFpL1lGc05kcTlGNGY4WmhLUU8vM0pVbzRibHU2SjRBZU45Ky9NZGhiUjhYWnZSMUtlVGxIMHJIbnk4QjgrY0tGamdNRmtQL3BhOHRBdHExOEZ1RDA1d29DZm14Q0VIRCtVeVVoMm05OG5PTUNnZlB2amwwOWw0WEJnTDZXQm9SUU53d0traCtBcjlEUlN6OUV5Vyt1ZVZOMFI2NlE3U2RRcjlYOUl4OFRNMzZnSXV0WG51NFpBZHc2RFcxWklGbHhFd0ZtZkRzRmR0c3V3ejFzR3c5NDZYVk1Ocy85ekFWNmQzeXByTjYvR2hEYnVqSU85ZVgwNW9SOTdGOFBYSHc3S0J0ZXZIaXhrNStKUG0wV0FHdy9DRGc5RGxwN1BPblpRR1E3SVJEdy9sSzIyZ1ZqNmRVQmNtcEFFSDBqbmltNGN4S2ZTL256amxmbVZHYjNTcDJlQjQ3QVpNdlp2cFBjeGQ4STNrQUFjQ2M4c0Vnd2wvcVVvQTd3MGtZanRPdVpZQ25nK1Q0SjhPS3JVcC9uci9GNDlOS05iak11cTNmOURlZXJEdlpUTW50eUNrZlg3dm01Y2VjaWFyOTQ4YUwvdHdwKzhTZ3hDNkRiMnd3Q1FObUZZV3RmUGh1UXh4N0RWUXNFa2owL0p1bENxNTZaeHpNRFVhOHlJRWo3OHA5dkhCaGl1L0J6cE1Kcjkvd2NTZUJPUFZ1L05JdWZJOXNIeWpQK2xKNzBEUC9zc2s0ZUVBUkljTDFVVnNqM2lUQW96ZUR0enJSTkpwUG5yMUkySHgvM2RORDcxMFJXUC9qSXI4YWhmWlRBUHV4ak91eWo0M0pqbFQ1WCt2MTQ2S0dIQnZBTC8rVFNMSUR1bDJEcDlzOGFCR3hEWUNNRkFmZGFPeHZ3OWtvZ2lPeFpJT0Q5UytlSEg1dTI2bWJ1Z0FEb1FTRzNqL2FYMG9tUFV3NFNWamNHdTNaSHJyYWVYK3QzbTVLR3Zkd20zZUZyZUdZZlFUKy96Sk8rbC83cDZYWnVYeTZERC9ZSk5YbkpSc3ZrQTUzRWRZb3dPQXdnMHpMdXlNY0kwQVA1RWc0NVRjN2hySmw5N3Rqb3R2cTVTNzRmZnZoaEdmd2VOdktLalZUNXdHN1BGd1FpbTRyWmdIdWRJeEJveHhpQVhnZ0dxUXhjT2thdFRDUWRGN1hYQW9MYU41c3BhRGFsMjduc1BsdnFjZnVWOGswdUNPeGFOTmdESWJ6OVBnWGMycjRvSUdRQVQ3ZEZzTnVHckEySHV6MmVmQlpQWDJzaDcxN1RLNGppV1JNdDNRdytaTkRiMTdoOEUvbENPcXVuNThqYlptcjIwakhKNTZEZzNnN3BNOHpjNkJlQW44cStCSUhJdm1JMmtPcDdkQ0RvRFZTSVM5QWVPVE9nKzFPcmJaSkJRbHJKVXhrWUFEMDRhSDVLMy9PeDVYUnIyamN0dVRLUTFpNEZpRndRU0wzUFFWM1REL2FOZ0x1MEx3WDRsSzhjNU8xckFtWW1IZ00vQmkyakg5cjVXTXV6ZW42c1l5L1Fhc2ZKdDhmQVBqaTJ2M3Zra1NIanp6d0JVYXYzVGlrSDVmeVBtZzNZeHNCT0N3VHVOYjZMTk16b2FYLzJPTXRtQmJUUDNNd2dkMDYwNDA2dHNzbVZjVktCSVduSHg4WGVyOGN1Mnh3WkJNYnFsa29POGlWNnVZeGUyNmZCbk91cTI0aUJBY1JncDlzbGNBL2FLZ0JQeHpFVzh1NDl2MzdDTDhZU2RkdnZGa0ZQank4WXY5RC9uTERuMjZMdUk0OCsyZ0ZTeHBrT0FuUzdKZ2pFMitXekFXQjdnUURRWndWK3JOR0Z6THFaZ2RSbkxpQm8rK2p4QjhlUVdaZGZ0SzJ0NkFFL3B2cHNud2NNSnp4d3hHYTd6ZmFkNUFMQ1Nsbm15UUV1K2NxK3p3Q2QyOVNVZzNqbXp2M21mSXdCdkRpV0RPUmRtd1I1SUYyamQrMzJmZUE0V29ZcmxXKzRYUzZyMThhZ3RlZHE5bG9md2JpVlJRVG1VUWQrMGpnbENGRDlxVUVnMTBjTW52R0JBSWl2RWZCeFNMTUNPdTdTWUtBZEgvY2xIYlAxV1I4VXJMMThMcVhnVUxvdHZzK3Q3RkhXNktkZ1hncjZUUVdFMG93L3B5ditjd3ByL1ZPcmVyaWYwakpRTGRnMSt4U2s1Z0E4SFI4ZkcvVW5RZDdwU05tODA0aytnc0tNM3I0U013YjY0SGlFY2RSazlaRk9UUmtuTlNiMytwWEhIdXM0MkRjUkJQaitYUVFDcXllczFtRVhpNTJ0TkN2Z1l5a0pCcTVmS3JVQlFmU1pDQXFxWHFwY3c4YkoyN1VBTWVaOWRuL20yVHY3ZEtFM2RWRVhrSUVLS0VHZ051dG43NlV5VE1rWWNzRWlkUytBQmpkUlp3VGdpWm1YVXNqYjkwRUh2YitEQjNxK0hZeWpBUFpVMTN6bHNjZUMveUFKN0FQTUpGRHNieUN3WTZmdGdwL0NXWUd6THdrR1FQelFzTkVCZ1JqWEJJVm9PL05relpyZ0VQU2JDQlMydjNTdHY2U2ZYV2Y3VGpaVjU5ZjJSMURNekFJMDNiSVpnQTUydXMzQkhyWUZsQXoyelFINHFBOWlOd1h5emcvL0NLYUFYdEwxeDdRajJBZnZPZmk1N0hvMmtMU3ZDQVN1djlKWlFlUXJFUXlBelFjRW9Dd29STzNNZjBxMzlPbWFZN1A3YUV4S1ZwK3pUOTNndFF1UmJ0Q2lrZ3dFQ1hpbjdFdExQVFUzZU5udDlMSlRucm5UZmRKNUtBVThVQVo1RWZDMm85NXZmQzRreUhNZkpSZGo0MzM3QzNwSlAyaDc3TktsRGlqN1o2MEpBcEsrcEx1cFFPRGFVbENTWmdWV3B5d1lCTFl6QndUck96d3U3WUtyazFSZ2lPelZ6THR3MmVhSXAydHVNdHZmTjVrcjY0OUxPVHpyTDRPUEJQU2tqUUIyMjM4NTNKa2JhNStCTzdXdmdYd3FrK2MrSk1qenNlUkFYeHhzWndSOVpGZWdyOW1iUzVjdWlmOWEreHdJZ0xLU2hYMmZuaFZvZlVyQmdQdXordk1HQkdtY3FhQkFmWlFHQm1tOHRRR0M5NTNTci9rdTdQT2R1aW5KbFlFMmMzTlhHY3lqOXlPZ3puVnJNbmQ1UENIYzdiNm84NzRQK1ZoS3l6VldkenprYzhjeUYraXo0eXpRRjMzeWM0OGUvRnlSN3hnVEJMaWZ1UU1CYnl1ZEZkajNkY0hBOVYwYkRFUS9tWUFBMUFVRnFoLzJRMjJVTWsvaUFtcHBrQkRibFgyU1NNZFk2Nk5XZDZyTVUrdlAvOE5xZmlLLy9HMEc1bDR2QTNYQnRmV1plUVMwNUtlMlJCUDRMc2ppcVk5YXlQT3hqc25tZWIvN0J2cEk1Ky9YOXRtSEtvU2xmVHNNQlB6OW1GbUIvTDZ1VEVUNzFnS0M2RmVaSVFSK1JPRGIxK0RlZ2tSZzROdmFMMVJwQVVJYVkzNmxUWG84WXFjNTNSRTZ1NUpjUUZEYnRkMVJabG53VDYvQTNMNVBBeDJRb1M3NTR2NVVzTnRCa1g3THNuZnFmd3pnQTk4elFKNjNsWmJZdUdUSG5ORVhmVXFmVGFaL0QzNHUreHdJdUg3cUluTXFHR1Q5Wk9yeVJmMFh6aEtzWFVGUXNJcHh1ekJqMFByaGZVVG5LQUg0ZURaWUJ2dGMwSkNrOUNMdXRvTkJhY1lQbE1FNjhsLzArR1lHa053NEtvRE8rMGlXWTN4RGZlWk8rNUQ4emduNGxCMlFoandmdzFqSVI3YUZOcUx2Z3F3KzVjKzA3TDljZlc3S2xnT0I1bWVPWU1EYjZ4OHVWaFlRVXVPUWJtSkt6UlNzcmQ1UDVGTUYvckJkRWlDay9xVCtTMy96VnROSzlaL3FkNStrSkNCb0paN1NVQ0w5M20vSjlRQ3BmKzRyQjNVZ0RYYXJPZy9jdVYvNmZncmdnZkdadlBRK2FOc0QwS2Q4Qm1XcXpvcW9tTHJRTm5jZzBIeE9tUlZ3bTVwZ0FFd1BDRlNuSmlnQTB3S0QxcC9vTnduODhIMjBGSFZpUnA3Nlhtenp4OU0zS2FrZlpnZDBrSXdKSXNVZ0J3S1lBekxRK1RoeWo1Uk9nZDNhajRjNzd5dGw2NlFtaXkveGw3SVY3UXZ0UlA4emc1NzdOaEwxOXprUWNKOHFWQXBuQnVMN2lRRkI3bU5hVU9CajB4OTdrTzdUK3NpUDM3ZFZCQXB0SEU2U0phUXRyczJueDF0VHRwbEx0S3hmQ3hKRlF5d0V1ZlhIQWFqMW00WTY3NmNFaG5SZjZVcWxYUUplc2hkOUZOcUovaXRxOUNtL0tkQnorZi90WEZVUUxDUk1YQUFBQUFCSlJVNUVya0pnZ2c9PSIgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsLTEsMCwxKSIgaGVpZ2h0PSIxIiB3aWR0aD0iMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSIvPgogICAgPC9nPgogICA8L2c+CiAgPC9nPgogPC9nPgo8L3N2Zz4K\",\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnNjkzNCIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjEwLjg3bW0iIHdpZHRoPSI0OS45NjZtbSIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDE3Ny4wNDM3NSA3NDcuMTYyNDkiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiA8ZGVmcyBpZD0iZGVmczY5MzYiPgogIDxsaW5lYXJHcmFkaWVudCBpZD0ibGluZWFyR3JhZGllbnQ1NTcxIiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxZS03IC0xNzA0LjMgLTE3MDQuMyAtMWUtNyA0NTk2LjYgMjgyOC42KSI+CiAgIDxzdG9wIGlkPSJzdG9wNTU3MyIgc3RvcC1jb2xvcj0iI2Y3ZDY0OSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTU3NSIgc3RvcC1jb2xvcj0iI2YxODM1NSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NTU1MyIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoNDU2LjIyIDAgMCAtNDU2LjIyIDQzNjguNCAzNDk0LjMpIj4KICAgPHN0b3AgaWQ9InN0b3A1NTU1IiBzdG9wLWNvbG9yPSIjY2FjOWM4IiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A1NTU3IiBzdG9wLWNvbG9yPSIjZjZmNmY2IiBvZmZzZXQ9Ii43NTI2OSIvPgogICA8c3RvcCBpZD0ic3RvcDU1NTkiIHN0b3AtY29sb3I9IiNkNGQzZDIiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDU1MzMiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDEzNDUuNSAwIDAgLTEzNDUuNSAzOTIzLjggMzA2NS45KSI+CiAgIDxzdG9wIGlkPSJzdG9wNTUzNSIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTUzNyIgc3RvcC1jb2xvcj0iI2IzYjNiMiIgb2Zmc2V0PSIuMSIvPgogICA8c3RvcCBpZD0ic3RvcDU1MzkiIHN0b3AtY29sb3I9IiNmZWZmZmYiIG9mZnNldD0iLjI0NzMxIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTU0MSIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8bGluZWFyR3JhZGllbnQgaWQ9ImxpbmVhckdyYWRpZW50NTQ1NSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGdyYWRpZW50VHJhbnNmb3JtPSJtYXRyaXgoMWUtNyAtMTcwNC4zIC0xNzA0LjMgLTFlLTcgNDU5Ni42IDI4MjguNikiPgogICA8c3RvcCBpZD0ic3RvcDU0NTciIHN0b3AtY29sb3I9IiNmM2I3MGMiIG9mZnNldD0iMCIvPgogICA8c3RvcCBpZD0ic3RvcDU0NTkiIHN0b3AtY29sb3I9IiNlNzNlMjAiIG9mZnNldD0iMSIvPgogIDwvbGluZWFyR3JhZGllbnQ+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDU0MzciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDQ1Ni4yMiAwIDAgLTQ1Ni4yMiA0MzY4LjQgMzQ5NC4zKSI+CiAgIDxzdG9wIGlkPSJzdG9wNTQzOSIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTQ0MSIgc3RvcC1jb2xvcj0iI2VjZWNlYyIgb2Zmc2V0PSIuNzUyNjkiLz4KICAgPHN0b3AgaWQ9InN0b3A1NDQzIiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg2MTIxLTMiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MTIzLTYiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0ibTQxNDIuNCAxOTAuMDNjMCA2Ny4yMzEgMjAzLjMxIDEyMS43NyA0NTQuMTQgMTIxLjc3czQ1NC4xNC01NC41MzkgNDU0LjE0LTEyMS43N2MwLTY3LjMyLTIwMy4zMS0xMjEuODYtNDU0LjE0LTEyMS44NnMtNDU0LjE0IDU0LjU0My00NTQuMTQgMTIxLjg2Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTU2Ny03IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTU2OS01IiBkPSJtNDQ3My43IDE1MzEuN2MtMzAuNDcgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnYyOTE0LjFjMjguNDMgMzEuMTggNjYuOTkgNTMgMTEwLjMyIDYwLjE0di0yOTc0LjJjMC0zMC40Ny0yNC43LTU1LjE2LTU1LjE2LTU1LjE2Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTU0OS02IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTU1MS0yIiBkPSJtNDQxOC41IDQ1MDF2MTE4OC42YzAgMzAuNDYgMjQuNjkgNTUuMTYgNTUuMTYgNTUuMTYgMzAuNDYgMCA1NS4xNi0yNC43IDU1LjE2LTU1LjE2di0xMTI4LjRjLTQzLjMzLTcuMTQtODEuODktMjguOTYtMTEwLjMyLTYwLjE0Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTUyOS03IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTUzMS0wIiBkPSJtNDYzMy4zIDU5NDEuOWgtNzMuNDRjLTE1NC4zNyAwLTI3OS45Ni0xMjUuNTktMjc5Ljk2LTI3OS45NnYtNDIwNS41Yy0yMTguMTEtMTE2LjIyLTM1Ni4wNy0zNDMuMjItMzU2LjA3LTU5My43IDAtMzcwLjk2IDMwMS43OS02NzIuNzUgNjcyLjc1LTY3Mi43NSAzNzAuOTUgMCA2NzIuNzQgMzAxLjc5IDY3Mi43NCA2NzIuNzUgMCAyNTAuNDgtMTM3Ljk1IDQ3Ny40OC0zNTYuMDcgNTkzLjd2NDIwNS41YzAgMTU0LjM3LTEyNS41OSAyNzkuOTYtMjc5Ljk1IDI3OS45Nm0wLTg4LjU4YzEwNS4yNiAwIDE5MS4zOS04Ni4xMiAxOTEuMzktMTkxLjM4di00MjYxLjJjMjA5LjI4LTg4Ljg1IDM1Ni4wNy0yOTYuMjUgMzU2LjA3LTUzNy45NCAwLTMyMi42My0yNjEuNTUtNTg0LjE4LTU4NC4xOC01ODQuMTgtMzIyLjY0IDAtNTg0LjE4IDI2MS41NS01ODQuMTggNTg0LjE4IDAgMjQxLjY5IDE0Ni43OCA0NDkuMDkgMzU2LjA3IDUzNy45NHY0MjYxLjJjMCAxMDUuMjYgODYuMTIgMTkxLjM4IDE5MS4zOSAxOTEuMzhoNzMuNDQiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1NTE1LTYiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1NTE3LTIiIGQ9Im00NTk2LjYgOTU1Ljg5Yy0xODkuMTYgMC0zNDIuNSA5Ny4yMTktMzQyLjUgMjE3LjE1IDAgMTE5LjkyIDE1My4zNCAyMTcuMTQgMzQyLjUgMjE3LjE0IDExOC41OSAwIDIyMy4xLTM4LjIxIDI4NC41Ny05Ni4yOCAzNi41OC0zNC41NSA1Ny45Mi03Ni4xMiA1Ny45Mi0xMjAuODYgMC04My4zOS03NC4xMy0xNTUuOC0xODIuOS0xOTIuMTgtNDcuNjUtMTUuOTM3LTEwMS45Ni0yNC45NjgtMTU5LjU5LTI0Ljk2OCIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDU0OTktNiIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDU1MDEtMSIgZD0ibTQ3NTYuMiA5ODAuODZjMTA4Ljc3IDM2LjM4MSAxODIuOSAxMDguNzkgMTgyLjkgMTkyLjE4di0xLjI0Yy0wLjczLTgyLjg3LTc0LjY3LTE1NC43My0xODIuOS0xOTAuOTRtMTgyLjkgMTkyLjE4YzAgNDQuNzQtMjEuMzQgODYuMzEtNTcuOTIgMTIwLjg2IDM2LjI0LTM0LjIzIDU3LjUzLTc1LjM2IDU3LjkyLTExOS42M3YtMS4yMyIvPgogIDwvY2xpcFBhdGg+CiAgPHJhZGlhbEdyYWRpZW50IGlkPSJyYWRpYWxHcmFkaWVudDU1MDMtOCIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGN5PSIwIiBjeD0iMCIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCg1ODQuMTggMCAwIC01ODQuMTggNDU5Ni42IDg2Mi43NSkiIHI9IjEiPgogICA8c3RvcCBpZD0ic3RvcDU1MDUtNyIgc3RvcC1jb2xvcj0iI2YzYjcwYyIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTUwNy05IiBzdG9wLWNvbG9yPSIjZTczZTIwIiBvZmZzZXQ9IjEiLz4KICA8L3JhZGlhbEdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1NDgzLTIiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1NDg1LTAiIGQ9Im00OTM5IDExNzEuOHYxLjI0IDEuMjMtMS4yMy0xLjI0Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTQ2Ny01IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTQ2OS05IiBkPSJtNTE4MC43IDg2Mi43NWMwLTMyMi42NC0yNjEuNTUtNTg0LjE4LTU4NC4xOC01ODQuMTgtMzIyLjY0IDAtNTg0LjE4IDI2MS41NC01ODQuMTggNTg0LjE4IDAgMzIyLjYzIDI2MS41NCA1ODQuMTggNTg0LjE4IDU4NC4xOCAzMjIuNjMgMCA1ODQuMTgtMjYxLjU1IDU4NC4xOC01ODQuMTgiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1NDUxLTkiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1NDUzLTciIGQ9Im00ODI0LjcgMTQwMC43Yy03MC4wOSAyOS43Ni0xNDcuMTggNDYuMjItMjI4LjExIDQ2LjIyLTgwLjk0IDAtMTU4LjAzLTE2LjQ2LTIyOC4xMS00Ni4yMnYyOTcxLjVjMCA0OS40NCAxOSA5NC42NyA1MC4wNyAxMjguNzJ2LTI5MTQuMWMwLTMwLjQ3IDI0LjY5LTU1LjE2IDU1LjE2LTU1LjE2IDMwLjQ2IDAgNTUuMTYgMjQuNjkgNTUuMTYgNTUuMTZ2Mjk3NC4yYzEwLjA5IDEuNjYgMjAuNDUgMi41MyAzMSAyLjUzaDczLjQ0YzEwNS4yNiAwIDE5MS4zOS04Ni4xMyAxOTEuMzktMTkxLjM5di0yOTcxLjUiLz4KICA8L2NsaXBQYXRoPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1NDMzLTEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg1NDM1LTIiIGQ9Im00ODI0LjcgNDM3Mi4yYzAgMTA1LjI2LTg2LjEzIDE5MS4zOS0xOTEuMzkgMTkxLjM5aC03My40NGMtMTAuNTUgMC0yMC45MS0wLjg3LTMxLTIuNTN2MTEyOC40YzAgMzAuNDYtMjQuNyA1NS4xNi01NS4xNiA1NS4xNi0zMC40NyAwLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTExODguNmMtMzEuMDctMzQuMDUtNTAuMDctNzkuMjgtNTAuMDctMTI4LjcydjEyODkuN2MwIDEwNS4yNiA4Ni4xMiAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtMTI4OS43Ii8+CiAgPC9jbGlwUGF0aD4KIDwvZGVmcz4KIDxnIGlkPSJsYXllcjEiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMTcuMDcgLTIwNC41MykiPgogIDxnIGZpbGw9IiNmZmYiPgogICA8cGF0aCBpZD0icGF0aDUxNDciIGQ9Im0zMDUuNTkgOTUxLjdjLTQ4Ljg5IDAtODguNTIzLTEwLjYzMi04OC41MjMtMjMuNzUgMC03LjA4NjQgMTEuNTcxLTEzLjQ0OSAyOS45MTktMTcuOCAwLjAzNSAwLjAzIDAuMDY3NSAwLjA1OCAwLjEwMjUgMC4wOS0xOC4yMiA0LjMzNS0yOS43IDEwLjY2My0yOS43IDE3LjcxIDAgMTMuMDY5IDM5LjQ4OSAyMy42NjQgODguMjAxIDIzLjY2NCA0OC43MTEgMCA4OC4yMDEtMTAuNTk1IDg4LjIwMS0yMy42NjQgMC03LjA0NzQtMTEuNDgxLTEzLjM3NS0yOS43MDEtMTcuNzEgMC4wMzYyLTAuMDMxIDAuMDY3NS0wLjA2IDAuMTAyNS0wLjA5IDE4LjM0OCA0LjM1MSAyOS45MTkgMTAuNzEzIDI5LjkxOSAxNy44IDAgMTMuMTE4LTM5LjYzMiAyMy43NS04OC41MjEgMjMuNzVtMzcuNTAyLTQ1LjE3NGMwLjA0MzctMC4wMjYgMC4wODc1LTAuMDU0IDAuMTMxMjUtMC4wNzktMC4wNDM4IDAuMDI1LTAuMDg3NSAwLjA1Mi0wLjEzMTI1IDAuMDc5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE0OSIgZD0ibTMwNS41OSA5NTEuNjFjLTQ4LjcxMyAwLTg4LjIwMS0xMC41OTUtODguMjAxLTIzLjY2NCAwLTcuMDQ3NCAxMS40OC0xMy4zNzUgMjkuNy0xNy43MSAwLjAzMzcgMC4wMjkgMC4wNyAwLjA2MSAwLjEwMjUgMC4wOTEtMTguMDg5IDQuMzE3NC0yOS40ODEgMTAuNjExLTI5LjQ4MSAxNy42MTkgMCAxMy4wMjEgMzkuMzQ1IDIzLjU3OCA4Ny44OCAyMy41NzhzODcuODgtMTAuNTU3IDg3Ljg4LTIzLjU3OGMwLTcuMDA3NC0xMS4zOTQtMTMuMzAxLTI5LjQ4Mi0xNy42MTkgMC4wMzM4LTAuMDMgMC4wNy0wLjA2MiAwLjEwMjUtMC4wOTEgMTguMjIgNC4zMzUgMjkuNzAxIDEwLjY2MyAyOS43MDEgMTcuNzEgMCAxMy4wNjktMzkuNDkgMjMuNjY0LTg4LjIwMSAyMy42NjRtMzcuMzcxLTQ1LjAxYzAuMDQ1LTAuMDI2IDAuMDg2My0wLjA1MSAwLjEzMTI1LTAuMDc4LTAuMDQ1IDAuMDI2LTAuMDg2MyAwLjA1MS0wLjEzMTI1IDAuMDc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE1MSIgZD0ibTMwNS41OSA5NTEuNTJjLTQ4LjUzNSAwLTg3Ljg4LTEwLjU1Ny04Ny44OC0yMy41NzggMC03LjAwNzQgMTEuMzkyLTEzLjMwMSAyOS40ODEtMTcuNjE5IDAuMDM2MiAwLjAzIDAuMDY4OCAwLjA1OSAwLjEwMzc1IDAuMDktMTcuOTYgNC4zMDI2LTI5LjI2NSAxMC41NjItMjkuMjY1IDE3LjUyOSAwIDEyLjk3NCAzOS4yMDEgMjMuNDkxIDg3LjU2IDIzLjQ5MSA0OC4zNTggMCA4Ny41NTktMTAuNTE4IDg3LjU1OS0yMy40OTEgMC02Ljk2NzItMTEuMzA1LTEzLjIyNi0yOS4yNjQtMTcuNTI5IDAuMDM1LTAuMDMxIDAuMDY3NS0wLjA2IDAuMTAyNS0wLjA5IDE4LjA4OSA0LjMxNzQgMjkuNDgyIDEwLjYxMSAyOS40ODIgMTcuNjE5IDAgMTMuMDIxLTM5LjM0NSAyMy41NzgtODcuODggMjMuNTc4bTM3LjI0LTQ0Ljg0NWMwLjA0MjUtMC4wMjUgMC4wODc1LTAuMDUyIDAuMTMxMjUtMC4wNzktMC4wNDM4IDAuMDI2LTAuMDg4NyAwLjA1NC0wLjEzMTI1IDAuMDc5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE1MyIgZD0ibTMwNS41OSA5NTEuNDRjLTQ4LjM1OSAwLTg3LjU2LTEwLjUxOC04Ny41Ni0yMy40OTEgMC02Ljk2NzIgMTEuMzA1LTEzLjIyNiAyOS4yNjUtMTcuNTI5IDAuMDMzNyAwLjAyOSAwLjA3IDAuMDYxIDAuMTAzNzUgMC4wOS0xNy44MyA0LjI4NjYtMjkuMDQ4IDEwLjUxMS0yOS4wNDggMTcuNDM5IDAgMTIuOTI2IDM5LjA1OCAyMy40MDYgODcuMjM5IDIzLjQwNiA0OC4xOCAwIDg3LjIzOS0xMC40OCA4Ny4yMzktMjMuNDA2IDAtNi45Mjc4LTExLjIxOC0xMy4xNTItMjkuMDQ4LTE3LjQzOSAwLjAzMjUtMC4wMjkgMC4wNy0wLjA2MSAwLjEwMzc1LTAuMDkgMTcuOTU5IDQuMzAyNiAyOS4yNjQgMTAuNTYyIDI5LjI2NCAxNy41MjkgMCAxMi45NzQtMzkuMjAxIDIzLjQ5MS04Ny41NTkgMjMuNDkxbTM3LjEwOC00NC42OGMwLjA0MjUtMC4wMjYgMC4wODg4LTAuMDUyIDAuMTMyNS0wLjA3OS0wLjA0MzggMC4wMjYtMC4wOSAwLjA1My0wLjEzMjUgMC4wNzkiLz4KICAgPHBhdGggaWQ9InBhdGg1MTU1IiBkPSJtMzA1LjU5IDk1MS4zNWMtNDguMTgxIDAtODcuMjM5LTEwLjQ4LTg3LjIzOS0yMy40MDYgMC02LjkyNzcgMTEuMjE4LTEzLjE1MiAyOS4wNDgtMTcuNDM5IDAuMDM1IDAuMDMxIDAuMDY3NSAwLjA2IDAuMTAzNzUgMC4wOTEtMTcuNzAxIDQuMjY5LTI4LjgzMSAxMC40NTktMjguODMxIDE3LjM0OCAwIDEyLjg3OSAzOC45MTUgMjMuMzIgODYuOTE5IDIzLjMyczg2LjkxOC0xMC40NDEgODYuOTE4LTIzLjMyYzAtNi44ODg2LTExLjEzLTEzLjA3OS0yOC44My0xNy4zNDggMC4wMzUtMC4wMzEgMC4wNjc1LTAuMDYgMC4xMDM3NS0wLjA5MSAxNy44MyA0LjI4NjYgMjkuMDQ4IDEwLjUxMSAyOS4wNDggMTcuNDM5IDAgMTIuOTI2LTM5LjA1OSAyMy40MDYtODcuMjM5IDIzLjQwNm0zNi45NzQtNDQuNTE2YzAuMDQ2My0wLjAyOCAwLjA4NzUtMC4wNTMgMC4xMzM3NS0wLjA3OS0wLjA0NjMgMC4wMjYtMC4wODc1IDAuMDUxLTAuMTMzNzUgMC4wNzkiLz4KICAgPHBhdGggaWQ9InBhdGg1MTU3IiBkPSJtMzA1LjU5IDk1MS4yN2MtNDguMDA0IDAtODYuOTE5LTEwLjQ0MS04Ni45MTktMjMuMzIgMC02Ljg4ODYgMTEuMTMtMTMuMDc5IDI4LjgzMS0xNy4zNDggMC4wMzUgMC4wMzEgMC4wNjg4IDAuMDYgMC4xMDM3NSAwLjA5LTE3LjU2OSA0LjI1MjUtMjguNjE0IDEwLjQxLTI4LjYxNCAxNy4yNTcgMCAxMi44MzEgMzguNzcxIDIzLjIzNCA4Ni41OTggMjMuMjM0IDQ3LjgyNiAwIDg2LjU5OC0xMC40MDMgODYuNTk4LTIzLjIzNCAwLTYuODQ3Ni0xMS4wNDYtMTMuMDA1LTI4LjYxNS0xNy4yNTYgMC4wMzYyLTAuMDMxIDAuMDY4Ny0wLjA2IDAuMTA1LTAuMDkxIDE3LjcgNC4yNjkgMjguODMgMTAuNDU5IDI4LjgzIDE3LjM0OCAwIDEyLjg3OS0zOC45MTQgMjMuMzItODYuOTE4IDIzLjMybTM2Ljg0MS00NC4zNTNjMC4wNDI1LTAuMDI1IDAuMDktMC4wNTIgMC4xMzI1LTAuMDc3LTAuMDQyNSAwLjAyNS0wLjA5IDAuMDUyLTAuMTMyNSAwLjA3NyIvPgogICA8cGF0aCBpZD0icGF0aDUxNTkiIGQ9Im0zMDUuNTkgOTUxLjE4Yy00Ny44MjYgMC04Ni41OTgtMTAuNDAzLTg2LjU5OC0yMy4yMzQgMC02Ljg0NzYgMTEuMDQ1LTEzLjAwNSAyOC42MTQtMTcuMjU3IDAuMDMzNyAwLjAzIDAuMDcxMiAwLjA2MiAwLjEwNSAwLjA5MS0xNy40NDEgNC4yMzQ5LTI4LjM5OCAxMC4zNTctMjguMzk4IDE3LjE2NiAwIDEyLjc4NCAzOC42MjYgMjMuMTQ4IDg2LjI3NiAyMy4xNDggNDcuNjQ5IDAgODYuMjc2LTEwLjM2NCA4Ni4yNzYtMjMuMTQ4IDAtNi44MDg2LTEwLjk1Ni0xMi45MzEtMjguMzk4LTE3LjE2NiAwLjAzMjUtMC4wMjkgMC4wNzEyLTAuMDYyIDAuMTAzNzUtMC4wOSAxNy41NjkgNC4yNTE1IDI4LjYxNSAxMC40MDkgMjguNjE1IDE3LjI1NiAwIDEyLjgzMS0zOC43NzEgMjMuMjM0LTg2LjU5OCAyMy4yMzRtMzYuNzA4LTQ0LjE4OGMwLjA0NS0wLjAyNiAwLjA4NzUtMC4wNTEgMC4xMzM3NS0wLjA3OS0wLjA0NjMgMC4wMjgtMC4wODg3IDAuMDUzLTAuMTMzNzUgMC4wNzkiLz4KICAgPHBhdGggaWQ9InBhdGg1MTYxIiBkPSJtMzA1LjU5IDk1MS4wOWMtNDcuNjUgMC04Ni4yNzYtMTAuMzY0LTg2LjI3Ni0yMy4xNDggMC02LjgwODYgMTAuOTU2LTEyLjkzMSAyOC4zOTgtMTcuMTY2IDAuMDM1IDAuMDMxIDAuMDY4OCAwLjA2IDAuMTA1IDAuMDkxLTE3LjMxMSA0LjIxODctMjguMTgyIDEwLjMwNy0yOC4xODIgMTcuMDc1IDAgMTIuNzM2IDM4LjQ4NCAyMy4wNjEgODUuOTU2IDIzLjA2MSA0Ny40NzEgMCA4NS45NTUtMTAuMzI1IDg1Ljk1NS0yMy4wNjEgMC02Ljc2ODUtMTAuODcxLTEyLjg1Ni0yOC4xODEtMTcuMDc1IDAuMDM1LTAuMDMxIDAuMDY4OC0wLjA2IDAuMTA1LTAuMDkxIDE3LjQ0MSA0LjIzNDggMjguMzk4IDEwLjM1NyAyOC4zOTggMTcuMTY2IDAgMTIuNzg0LTM4LjYyOCAyMy4xNDctODYuMjc2IDIzLjE0N20zNi41NzItNDQuMDI0YzAuMDQzOC0wLjAyNSAwLjA5MTMtMC4wNTIgMC4xMzUtMC4wNzctMC4wNDM4IDAuMDI1LTAuMDkxMyAwLjA1Mi0wLjEzNSAwLjA3NyIvPgogICA8cGF0aCBpZD0icGF0aDUxNjMiIGQ9Im0zMDUuNTkgOTUxLjAxYy00Ny40NzMgMC04NS45NTYtMTAuMzI1LTg1Ljk1Ni0yMy4wNjEgMC02Ljc2ODUgMTAuODcxLTEyLjg1NiAyOC4xODItMTcuMDc1IDAuMDM1IDAuMDMgMC4wNjg4IDAuMDYgMC4xMDUgMC4wOS0xNy4xODQgNC4yMDIxLTI3Ljk2NiAxMC4yNTYtMjcuOTY2IDE2Ljk4NSAwIDEyLjY4OCAzOC4zNCAyMi45NzYgODUuNjM1IDIyLjk3NnM4NS42MzUtMTAuMjg4IDg1LjYzNS0yMi45NzZjMC02LjcyOS0xMC43ODQtMTIuNzgzLTI3Ljk2Ni0xNi45ODUgMC4wMzUtMC4wMyAwLjA2ODctMC4wNiAwLjEwNS0wLjA5IDE3LjMxIDQuMjE4OCAyOC4xODEgMTAuMzA3IDI4LjE4MSAxNy4wNzUgMCAxMi43MzYtMzguNDg0IDIzLjA2MS04NS45NTUgMjMuMDYxbTM2LjQzOC00My44NThjMC4wNDUtMC4wMjYgMC4wODg4LTAuMDUzIDAuMTM1LTAuMDc5LTAuMDQ2MyAwLjAyNi0wLjA5IDAuMDUzLTAuMTM1IDAuMDc5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE2NSIgZD0ibTMwNS41OSA5NTAuOTJjLTQ3LjI5NSAwLTg1LjYzNS0xMC4yODgtODUuNjM1LTIyLjk3NiAwLTYuNzI5IDEwLjc4Mi0xMi43ODMgMjcuOTY2LTE2Ljk4NSAwLjAzMzcgMC4wMjkgMC4wNzEyIDAuMDYyIDAuMTA1IDAuMDkxLTE3LjA1NCA0LjE4NDUtMjcuNzUxIDEwLjIwNC0yNy43NTEgMTYuODk0IDAgMTIuNjQxIDM4LjE5OCAyMi44OSA4NS4zMTUgMjIuODkgNDcuMTE4IDAgODUuMzE0LTEwLjI0OSA4NS4zMTQtMjIuODkgMC02LjY4OS0xMC42OTgtMTIuNzA5LTI3Ljc1MS0xNi44OTQgMC4wMzM3LTAuMDI5IDAuMDcyNS0wLjA2MiAwLjEwNjI1LTAuMDkxIDE3LjE4MiA0LjIwMjEgMjcuOTY2IDEwLjI1NiAyNy45NjYgMTYuOTg1IDAgMTIuNjg4LTM4LjM0IDIyLjk3Ni04NS42MzUgMjIuOTc2bTM2LjMwMS00My42OTZjMC4wNDYzLTAuMDI2IDAuMDktMC4wNTEgMC4xMzYyNS0wLjA3Ny0wLjA0NjMgMC4wMjYtMC4wOSAwLjA1MS0wLjEzNjI1IDAuMDc3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE2NyIgZD0ibTMwNS41OSA5NTAuODRjLTQ3LjExOCAwLTg1LjMxNS0xMC4yNDktODUuMzE1LTIyLjg5IDAtNi42ODkgMTAuNjk4LTEyLjcwOSAyNy43NTEtMTYuODk0IDAuMDM2MiAwLjAzIDAuMDcgMC4wNiAwLjEwNjI1IDAuMDktMTYuOTI1IDQuMTY5LTI3LjUzNiAxMC4xNTQtMjcuNTM2IDE2LjgwNCAwIDEyLjU5NCAzOC4wNTIgMjIuODA0IDg0Ljk5NCAyMi44MDQgNDYuOTQgMCA4NC45OTItMTAuMjEgODQuOTkyLTIyLjgwNCAwLTYuNjQ5OS0xMC42MTEtMTIuNjM1LTI3LjUzNi0xNi44MDQgMC4wMzYyLTAuMDMgMC4wNzEzLTAuMDYgMC4xMDYyNS0wLjA5IDE3LjA1NCA0LjE4NDUgMjcuNzUxIDEwLjIwNCAyNy43NTEgMTYuODk0IDAgMTIuNjQxLTM4LjE5NiAyMi44OS04NS4zMTQgMjIuODltMzYuMTY2LTQzLjUzMWMwLjA0MzgtMC4wMjYgMC4wOTEzLTAuMDU0IDAuMTM1LTAuMDc5LTAuMDQzOCAwLjAyNS0wLjA5MTMgMC4wNTMtMC4xMzUgMC4wNzkiLz4KICAgPHBhdGggaWQ9InBhdGg1MTY5IiBkPSJtMzA1LjU5IDk1MC43NWMtNDYuOTQxIDAtODQuOTk0LTEwLjIxLTg0Ljk5NC0yMi44MDQgMC02LjY0OTkgMTAuNjExLTEyLjYzNSAyNy41MzYtMTYuODA0IDAuMDM2MyAwLjAzMSAwLjA3IDAuMDYgMC4xMDYyNSAwLjA5MS0xNi43OTYgNC4xNTEzLTI3LjMyMSAxMC4xMDItMjcuMzIxIDE2LjcxMiAwIDEyLjU0NiAzNy45MDkgMjIuNzE3IDg0LjY3MyAyMi43MTcgNDYuNzYyIDAgODQuNjcyLTEwLjE3MSA4NC42NzItMjIuNzE3IDAtNi42MDk5LTEwLjUyNS0xMi41NjEtMjcuMzIyLTE2LjcxMiAwLjAzNjItMC4wMzEgMC4wNzEyLTAuMDYgMC4xMDYyNS0wLjA5MSAxNi45MjUgNC4xNjkgMjcuNTM2IDEwLjE1NCAyNy41MzYgMTYuODA0IDAgMTIuNTk0LTM4LjA1MiAyMi44MDQtODQuOTkyIDIyLjgwNG0zNi4wMjktNDMuMzY4YzAuMDQ2My0wLjAyNiAwLjA5MTMtMC4wNTIgMC4xMzc1LTAuMDc3LTAuMDQ2MyAwLjAyNS0wLjA5MTMgMC4wNTEtMC4xMzc1IDAuMDc3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE3MSIgZD0ibTMwNS41OSA5NTAuNjZjLTQ2Ljc2NCAwLTg0LjY3My0xMC4xNzEtODQuNjczLTIyLjcxNyAwLTYuNjA5OSAxMC41MjUtMTIuNTYxIDI3LjMyMS0xNi43MTIgMC4wMzYyIDAuMDMgMC4wNzEyIDAuMDYgMC4xMDYyNSAwLjA5LTE2LjY2OCA0LjEzNTMtMjcuMTA4IDEwLjA1My0yNy4xMDggMTYuNjIyIDAgMTIuNDk5IDM3Ljc2NiAyMi42MzEgODQuMzUzIDIyLjYzMSA0Ni41ODYgMCA4NC4zNTEtMTAuMTMzIDg0LjM1MS0yMi42MzEgMC02LjU2OTktMTAuNDM5LTEyLjQ4Ny0yNy4xMDgtMTYuNjIyIDAuMDM2Mi0wLjAzIDAuMDcxMi0wLjA2IDAuMTA2MjUtMC4wOSAxNi43OTggNC4xNTE0IDI3LjMyMiAxMC4xMDIgMjcuMzIyIDE2LjcxMiAwIDEyLjU0Ni0zNy45MSAyMi43MTctODQuNjcyIDIyLjcxN20zNS44OTEtNDMuMjA0YzAuMDQ2My0wLjAyNiAwLjA5MTMtMC4wNTEgMC4xMzc1LTAuMDc4LTAuMDQ2MyAwLjAyNi0wLjA5MTMgMC4wNTEtMC4xMzc1IDAuMDc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE3MyIgZD0ibTMwNS41OSA5NTAuNThjLTQ2LjU4NiAwLTg0LjM1My0xMC4xMzMtODQuMzUzLTIyLjYzMSAwLTYuNTY5OSAxMC40NC0xMi40ODcgMjcuMTA4LTE2LjYyMiAwLjAzNjIgMC4wMzEgMC4wNzEzIDAuMDYyIDAuMTA3NSAwLjA5MS0xNi41NDEgNC4xMTc2LTI2Ljg5NCAxMC0yNi44OTQgMTYuNTMxIDAgMTIuNDUxIDM3LjYyMSAyMi41NDUgODQuMDMxIDIyLjU0NSA0Ni40MDkgMCA4NC4wMzEtMTAuMDk0IDg0LjAzMS0yMi41NDUgMC02LjUzMTMtMTAuMzUyLTEyLjQxNC0yNi44OTQtMTYuNTMxIDAuMDM1LTAuMDMgMC4wNzEyLTAuMDYgMC4xMDYyNS0wLjA5MSAxNi42NjkgNC4xMzUzIDI3LjEwOCAxMC4wNTMgMjcuMTA4IDE2LjYyMiAwIDEyLjQ5OS0zNy43NjUgMjIuNjMxLTg0LjM1MSAyMi42MzFtMzUuNzU0LTQzLjA0YzAuMDQ2My0wLjAyNSAwLjA5MjUtMC4wNTEgMC4xMzc1LTAuMDc4LTAuMDQ1IDAuMDI2LTAuMDkxMiAwLjA1My0wLjEzNzUgMC4wNzgiLz4KICAgPHBhdGggaWQ9InBhdGg1MTc1IiBkPSJtMzA1LjU5IDk1MC40OWMtNDYuNDEgMC04NC4wMzEtMTAuMDk0LTg0LjAzMS0yMi41NDUgMC02LjUzMTIgMTAuMzUyLTEyLjQxNCAyNi44OTQtMTYuNTMxIDAuMDM1IDAuMDMgMC4wNzEyIDAuMDYgMC4xMDc1IDAuMDkxLTE2LjQxNSA0LjEwMDEtMjYuNjgxIDkuOTQ4Ny0yNi42ODEgMTYuNDQgMCAxMi40MDQgMzcuNDc5IDIyLjQ1OSA4My43MTEgMjIuNDU5IDQ2LjIzMSAwIDgzLjcxLTEwLjA1NSA4My43MS0yMi40NTkgMC02LjQ5MTMtMTAuMjY2LTEyLjM0LTI2LjY4LTE2LjQ0IDAuMDM2Mi0wLjAzMSAwLjA3MTItMC4wNjIgMC4xMDc1LTAuMDkxIDE2LjU0MSA0LjExNzYgMjYuODk0IDEwIDI2Ljg5NCAxNi41MzEgMCAxMi40NTEtMzcuNjIyIDIyLjU0NS04NC4wMzEgMjIuNTQ1bTM1LjYxNi00Mi44NzZjMC4wNDUtMC4wMjUgMC4wOTEzLTAuMDUxIDAuMTM3NS0wLjA3OC0wLjA0NjMgMC4wMjYtMC4wOTI1IDAuMDUyLTAuMTM3NSAwLjA3OCIvPgogICA8cGF0aCBpZD0icGF0aDUxNzciIGQ9Im0zMDUuNTkgOTUwLjQxYy00Ni4yMzMgMC04My43MTEtMTAuMDU1LTgzLjcxMS0yMi40NTkgMC02LjQ5MTIgMTAuMjY2LTEyLjM0IDI2LjY4MS0xNi40NCAwLjAzNSAwLjAzIDAuMDcxMyAwLjA2IDAuMTA3NSAwLjA5LTE2LjI4OSA0LjA4NC0yNi40NjggOS44OTc1LTI2LjQ2OCAxNi4zNSAwIDEyLjM1NiAzNy4zMzUgMjIuMzczIDgzLjM5IDIyLjM3M3M4My4zOS0xMC4wMTYgODMuMzktMjIuMzczYzAtNi40NTI2LTEwLjE4LTEyLjI2Ni0yNi40NjgtMTYuMzUgMC4wMzYyLTAuMDMgMC4wNzEzLTAuMDYgMC4xMDc1LTAuMDkgMTYuNDE0IDQuMTAwMSAyNi42OCA5Ljk0ODggMjYuNjggMTYuNDQgMCAxMi40MDQtMzcuNDc5IDIyLjQ1OS04My43MSAyMi40NTltMzUuNDc2LTQyLjcxMmMwLjA0NjMtMC4wMjYgMC4wOTM3LTAuMDUzIDAuMTQtMC4wNzktMC4wNDYzIDAuMDI2LTAuMDkzNyAwLjA1Mi0wLjE0IDAuMDc5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE3OSIgZD0ibTMwNS41OSA5NTAuMzJjLTQ2LjA1NSAwLTgzLjM5LTEwLjAxNi04My4zOS0yMi4zNzMgMC02LjQ1MjYgMTAuMTc5LTEyLjI2NiAyNi40NjgtMTYuMzUgMC4wMzYyIDAuMDMgMC4wNzEzIDAuMDYgMC4xMDc1IDAuMDkxLTE2LjE1OSA0LjA2NjQtMjYuMjU0IDkuODQ2MS0yNi4yNTQgMTYuMjU5IDAgMTIuMzA5IDM3LjE5IDIyLjI4OCA4My4wNjkgMjIuMjg4IDQ1Ljg3OCAwIDgzLjA2OS05Ljk3OSA4My4wNjktMjIuMjg4IDAtNi40MTI2LTEwLjA5NS0xMi4xOTItMjYuMjU1LTE2LjI1OSAwLjAzNjItMC4wMyAwLjA3MjUtMC4wNjEgMC4xMDg3NS0wLjA5MSAxNi4yODggNC4wODQgMjYuNDY4IDkuODk3NSAyNi40NjggMTYuMzUgMCAxMi4zNTYtMzcuMzM1IDIyLjM3My04My4zOSAyMi4zNzNtMzUuMzM4LTQyLjU0N2MwLjA0NjMtMC4wMjYgMC4wOTI1LTAuMDUzIDAuMTM4NzUtMC4wNzgtMC4wNDYzIDAuMDI1LTAuMDkyNSAwLjA1MS0wLjEzODc1IDAuMDc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE4MSIgZD0ibTMwNS41OSA5NTAuMjNjLTQ1Ljg3OSAwLTgzLjA2OS05Ljk3OS04My4wNjktMjIuMjg4IDAtNi40MTI2IDEwLjA5NS0xMi4xOTIgMjYuMjU0LTE2LjI1OSAwLjAzNjIgMC4wMyAwLjA3MjUgMC4wNiAwLjEwODc1IDAuMDktMTYuMDMxIDQuMDQ4OC0yNi4wNDIgOS43OTY1LTI2LjA0MiAxNi4xNjkgMCAxMi4yNjEgMzcuMDQ4IDIyLjIwMSA4Mi43NDkgMjIuMjAxIDQ1LjcgMCA4Mi43NDgtOS45Mzk5IDgyLjc0OC0yMi4yMDEgMC02LjM3MjUtMTAuMDExLTEyLjEyLTI2LjA0MS0xNi4xNjkgMC4wMzUtMC4wMyAwLjA3MTMtMC4wNiAwLjEwNzUtMC4wOSAxNi4xNiA0LjA2NjMgMjYuMjU1IDkuODQ2MSAyNi4yNTUgMTYuMjU5IDAgMTIuMzA5LTM3LjE5MSAyMi4yODgtODMuMDY5IDIyLjI4OG0zNS4xOTgtNDIuMzg2YzAuMDQ2My0wLjAyNSAwLjA5MzctMC4wNTEgMC4xNC0wLjA3Ni0wLjA0NjMgMC4wMjUtMC4wOTM3IDAuMDUxLTAuMTQgMC4wNzYiLz4KICAgPHBhdGggaWQ9InBhdGg1MTgzIiBkPSJtMzA1LjU5IDk1MC4xNWMtNDUuNzAxIDAtODIuNzQ5LTkuOTQtODIuNzQ5LTIyLjIwMSAwLTYuMzcyNSAxMC4wMTEtMTIuMTIgMjYuMDQyLTE2LjE2OSAwLjAzNjIgMC4wMzEgMC4wNzI1IDAuMDYyIDAuMTA4NzUgMC4wOTEtMTUuOTA2IDQuMDMxMy0yNS44MyA5Ljc0NDEtMjUuODMgMTYuMDc4IDAgMTIuMjE0IDM2LjkwNCAyMi4xMTUgODIuNDI4IDIyLjExNSA0NS41MjIgMCA4Mi40MjgtOS45MDA5IDgyLjQyOC0yMi4xMTUgMC02LjMzMzUtOS45MjM4LTEyLjA0Ni0yNS44My0xNi4wNzggMC4wMzYyLTAuMDMgMC4wNzI1LTAuMDYgMC4xMDg3NS0wLjA5MSAxNi4wMyA0LjA0ODkgMjYuMDQxIDkuNzk2NSAyNi4wNDEgMTYuMTY5IDAgMTIuMjYxLTM3LjA0OCAyMi4yMDEtODIuNzQ4IDIyLjIwMW0zNS4wNTYtNDIuMjIzYzAuMDQ4Ny0wLjAyNiAwLjA5MjUtMC4wNSAwLjE0MTI1LTAuMDc3LTAuMDQ4NyAwLjAyNy0wLjA5MjUgMC4wNTEtMC4xNDEyNSAwLjA3NyIvPgogICA8cGF0aCBpZD0icGF0aDUxODUiIGQ9Im0zMDUuNTkgOTUwLjA2Yy00NS41MjQgMC04Mi40MjgtOS45MDA5LTgyLjQyOC0yMi4xMTUgMC02LjMzMzUgOS45MjM4LTEyLjA0NiAyNS44My0xNi4wNzggMC4wMzYyIDAuMDMgMC4wNzI1IDAuMDYyIDAuMTA4NzUgMC4wOTEtMTUuNzc5IDQuMDEzNy0yNS42MTkgOS42OTE0LTI1LjYxOSAxNS45ODYgMCAxMi4xNjYgMzYuNzYxIDIyLjAyOSA4Mi4xMDggMjIuMDI5IDQ1LjM0NiAwIDgyLjEwNi05Ljg2MjggODIuMTA2LTIyLjAyOSAwLTYuMjk0OS05LjgzODgtMTEuOTczLTI1LjYxOS0xNS45ODYgMC4wMzYyLTAuMDMgMC4wNzM4LTAuMDYxIDAuMTEtMC4wOTEgMTUuOTA2IDQuMDMxMyAyNS44MyA5Ljc0NDIgMjUuODMgMTYuMDc4IDAgMTIuMjE0LTM2LjkwNSAyMi4xMTUtODIuNDI4IDIyLjExNW0zNC45MTUtNDIuMDU5YzAuMDQ2My0wLjAyNSAwLjA5NS0wLjA1MSAwLjE0MTI1LTAuMDc4LTAuMDQ2MyAwLjAyNi0wLjA5NSAwLjA1My0wLjE0MTI1IDAuMDc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE4NyIgZD0ibTMwNS41OSA5NDkuOThjLTQ1LjM0NiAwLTgyLjEwOC05Ljg2MjgtODIuMTA4LTIyLjAyOSAwLTYuMjk0OSA5Ljg0LTExLjk3MyAyNS42MTktMTUuOTg2IDAuMDM2MyAwLjAzIDAuMDczOCAwLjA2IDAuMTEgMC4wOS0xNS42NTIgMy45OTc3LTI1LjQwOCA5LjY0MTctMjUuNDA4IDE1Ljg5NiAwIDEyLjExOSAzNi42MTYgMjEuOTQyIDgxLjc4NiAyMS45NDIgNDUuMTY5IDAgODEuNzg1LTkuODIzNyA4MS43ODUtMjEuOTQyIDAtNi4yNTQ4LTkuNzUzOC0xMS44OTktMjUuNDA2LTE1Ljg5NiAwLjAzNjItMC4wMyAwLjA3MjUtMC4wNiAwLjEwODc1LTAuMDkgMTUuNzggNC4wMTM4IDI1LjYxOSA5LjY5MTUgMjUuNjE5IDE1Ljk4NiAwIDEyLjE2Ni0zNi43NiAyMi4wMjktODIuMTA2IDIyLjAyOW0zNC43NzItNDEuODk1YzAuMDQ4Ny0wLjAyNiAwLjA5MzctMC4wNTEgMC4xNDI1LTAuMDc4LTAuMDQ4NyAwLjAyNi0wLjA5MzcgMC4wNTEtMC4xNDI1IDAuMDc4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE4OSIgZD0ibTMwNS41OSA5NDkuODljLTQ1LjE3IDAtODEuNzg2LTkuODIzNy04MS43ODYtMjEuOTQyIDAtNi4yNTQ5IDkuNzU1LTExLjg5OSAyNS40MDgtMTUuODk2IDAuMDM2MiAwLjAzIDAuMDcyNSAwLjA2MiAwLjExIDAuMDkxLTE1LjUyOCAzLjk3OTktMjUuMTk2IDkuNTg4OS0yNS4xOTYgMTUuODA1IDAgMTIuMDcxIDM2LjQ3MiAyMS44NTYgODEuNDY1IDIxLjg1NiA0NC45OTEgMCA4MS40NjUtOS43ODUyIDgxLjQ2NS0yMS44NTYgMC02LjIxNjItOS42Ny0xMS44MjUtMjUuMTk2LTE1LjgwNSAwLjAzNjItMC4wMyAwLjA3MzgtMC4wNjEgMC4xMS0wLjA5MSAxNS42NTIgMy45OTc2IDI1LjQwNiA5LjY0MTYgMjUuNDA2IDE1Ljg5NiAwIDEyLjExOS0zNi42MTYgMjEuOTQyLTgxLjc4NSAyMS45NDJtMzQuNjMtNDEuNzMxYzAuMDQ3NS0wLjAyNSAwLjA5NjItMC4wNTMgMC4xNDI1LTAuMDc4LTAuMDQ2MyAwLjAyNS0wLjA5NSAwLjA1Mi0wLjE0MjUgMC4wNzgiLz4KICAgPHBhdGggaWQ9InBhdGg1MTkxIiBkPSJtMzA1LjU5IDk0OS44Yy00NC45OTMgMC04MS40NjUtOS43ODUyLTgxLjQ2NS0yMS44NTYgMC02LjIxNjIgOS42Njg4LTExLjgyNSAyNS4xOTYtMTUuODA1IDAuMDM2MiAwLjAzIDAuMDczOCAwLjA2MiAwLjExIDAuMDkxLTE1LjM5OCAzLjk2MTQtMjQuOTg2IDkuNTM3Ny0yNC45ODYgMTUuNzE0IDAgMTIuMDI0IDM2LjMzIDIxLjc3IDgxLjE0NSAyMS43NyA0NC44MTQgMCA4MS4xNDQtOS43NDYxIDgxLjE0NC0yMS43NyAwLTYuMTc2Mi05LjU4NzUtMTEuNzUyLTI0Ljk4NS0xNS43MTQgMC4wMzYyLTAuMDMgMC4wNzM3LTAuMDYxIDAuMTEtMC4wOTEgMTUuNTI2IDMuOTc5OSAyNS4xOTYgOS41ODg5IDI1LjE5NiAxNS44MDUgMCAxMi4wNzEtMzYuNDc0IDIxLjg1Ni04MS40NjUgMjEuODU2bTM0LjQ4OC00MS41NjljMC4wNDg3LTAuMDI1IDAuMDk1LTAuMDUgMC4xNDI1LTAuMDc2LTAuMDQ3NSAwLjAyNi0wLjA5MzcgMC4wNTEtMC4xNDI1IDAuMDc2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTE5MyIgZD0ibTMwNS41OSA5NDkuNzJjLTQ0LjgxNSAwLTgxLjE0NS05Ljc0NjEtODEuMTQ1LTIxLjc3IDAtNi4xNzYyIDkuNTg4OC0xMS43NTIgMjQuOTg2LTE1LjcxNCAwLjAzNjIgMC4wMjkgMC4wNzM3IDAuMDYgMC4xMSAwLjA5LTE1LjI3MiAzLjk0NDgtMjQuNzc1IDkuNDg1OC0yNC43NzUgMTUuNjI0IDAgMTEuOTc2IDM2LjE4NiAyMS42ODUgODAuODI0IDIxLjY4NXM4MC44MjItOS43MDkgODAuODIyLTIxLjY4NWMwLTYuMTM3Ny05LjUwMTItMTEuNjc5LTI0Ljc3NS0xNS42MjQgMC4wMzYyLTAuMDMgMC4wNzUtMC4wNjIgMC4xMTEyNS0wLjA5IDE1LjM5OCAzLjk2MTQgMjQuOTg1IDkuNTM3NyAyNC45ODUgMTUuNzE0IDAgMTIuMDI0LTM2LjMzIDIxLjc3LTgxLjE0NCAyMS43N20zNC4zNDQtNDEuNDA1YzAuMDQ2My0wLjAyNSAwLjA5NjMtMC4wNTIgMC4xNDM3NS0wLjA3Ny0wLjA0NzUgMC4wMjUtMC4wOTc1IDAuMDUyLTAuMTQzNzUgMC4wNzciLz4KICA8L2c+CiAgPGc+CiAgIDxwYXRoIGlkPSJwYXRoNTE5NSIgZD0ibTMwNS41OSA5NDkuNjNjLTQ0LjYzOCAwLTgwLjgyNC05LjcwOS04MC44MjQtMjEuNjg1IDAtNi4xMzc3IDkuNTAyNS0xMS42NzkgMjQuNzc1LTE1LjYyNCAwLjAzODcgMC4wMzEgMC4wNzI1IDAuMDYgMC4xMTEyNSAwLjA5MS0xNS4xNDYgMy45Mjc4LTI0LjU2NiA5LjQzNTEtMjQuNTY2IDE1LjUzMyAwIDExLjkyOSAzNi4wNDIgMjEuNTk5IDgwLjUwNCAyMS41OTkgNDQuNDYgMCA4MC41MDItOS42Njk5IDgwLjUwMi0yMS41OTkgMC02LjA5NzctOS40Mi0xMS42MDUtMjQuNTY2LTE1LjUzMyAwLjAzODctMC4wMzEgMC4wNzI1LTAuMDYgMC4xMTEyNS0wLjA5MSAxNS4yNzQgMy45NDQ4IDI0Ljc3NSA5LjQ4NTggMjQuNzc1IDE1LjYyNCAwIDExLjk3Ni0zNi4xODUgMjEuNjg1LTgwLjgyMiAyMS42ODVtMzQuMTk5LTQxLjI0NGMwLjA0ODctMC4wMjUgMC4wOTYyLTAuMDUgMC4xNDUtMC4wNzctMC4wNDg3IDAuMDI2LTAuMDk2MiAwLjA1MS0wLjE0NSAwLjA3NyIgZmlsbD0iI2ZlZmZmZiIvPgogICA8cGF0aCBpZD0icGF0aDUxOTciIGQ9Im0zMDUuNTkgOTQ5LjU1Yy00NC40NjEgMC04MC41MDQtOS42Ny04MC41MDQtMjEuNTk5IDAtNi4wOTc2IDkuNDItMTEuNjA1IDI0LjU2Ni0xNS41MzMgMC4wMzYyIDAuMDMgMC4wNzUgMC4wNjIgMC4xMTEyNSAwLjA5MS0xNS4wMjIgMy45MTAxLTI0LjM1NiA5LjM4MjctMjQuMzU2IDE1LjQ0MSAwIDExLjg4MSAzNS44OTkgMjEuNTEzIDgwLjE4MyAyMS41MTMgNDQuMjgyIDAgODAuMTgxLTkuNjMxMyA4MC4xODEtMjEuNTEzIDAtNi4wNTg2LTkuMzMzOC0xMS41MzEtMjQuMzU2LTE1LjQ0MSAwLjAzNzUtMC4wMyAwLjA3NS0wLjA2MSAwLjExMTI1LTAuMDkxIDE1LjE0NiAzLjkyNzggMjQuNTY2IDkuNDM1MiAyNC41NjYgMTUuNTMzIDAgMTEuOTI5LTM2LjA0MiAyMS41OTktODAuNTAyIDIxLjU5OW0zNC4wNTQtNDEuMDhjMC4wNDg3LTAuMDI2IDAuMDk2Mi0wLjA1MSAwLjE0NS0wLjA3Ny0wLjA0ODcgMC4wMjYtMC4wOTYyIDAuMDUxLTAuMTQ1IDAuMDc3IiBmaWxsPSIjZmVmZmZmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTE5OSIgZD0ibTMwNS41OSA5NDkuNDZjLTQ0LjI4NCAwLTgwLjE4My05LjYzMTMtODAuMTgzLTIxLjUxMyAwLTYuMDU4NiA5LjMzMzgtMTEuNTMxIDI0LjM1Ni0xNS40NDEgMC4wMzYyIDAuMDI5IDAuMDc1IDAuMDYyIDAuMTExMjUgMC4wOS0xNC44OTUgMy44OTIyLTI0LjE0NiA5LjMzMjctMjQuMTQ2IDE1LjM1MSAwIDExLjgzNCAzNS43NTUgMjEuNDI2IDc5Ljg2MSAyMS40MjZzNzkuODYxLTkuNTkyMyA3OS44NjEtMjEuNDI2YzAtNi4wMTg1LTkuMjUxMi0xMS40NTktMjQuMTQ4LTE1LjM1MSAwLjAzNjItMC4wMjkgMC4wNzUtMC4wNjIgMC4xMTEyNS0wLjA5IDE1LjAyMiAzLjkxMDIgMjQuMzU2IDkuMzgyOCAyNC4zNTYgMTUuNDQxIDAgMTEuODgxLTM1Ljg5OSAyMS41MTMtODAuMTgxIDIxLjUxM20zMy45MDktNDAuOTE3YzAuMDQ4Ny0wLjAyNSAwLjA5NjItMC4wNSAwLjE0NS0wLjA3Ny0wLjA0ODcgMC4wMjYtMC4wOTYyIDAuMDUxLTAuMTQ1IDAuMDc3IiBmaWxsPSIjZmVmZWZmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTIwMSIgZD0ibTMwNS41OSA5NDkuMzdjLTQ0LjEwNiAwLTc5Ljg2MS05LjU5MjMtNzkuODYxLTIxLjQyNiAwLTYuMDE4NSA5LjI1MTItMTEuNDU5IDI0LjE0Ni0xNS4zNTEgMC4wMzg3IDAuMDMxIDAuMDczOCAwLjA2IDAuMTEyNSAwLjA5MS0xNC43NyAzLjg3NTUtMjMuOTM5IDkuMjgwMi0yMy45MzkgMTUuMjYgMCAxMS43ODYgMzUuNjExIDIxLjM0IDc5LjU0MSAyMS4zNCA0My45MjkgMCA3OS41NC05LjU1MzcgNzkuNTQtMjEuMzQgMC01Ljk4LTkuMTY3NS0xMS4zODUtMjMuOTM5LTE1LjI2IDAuMDM4Ny0wLjAzMSAwLjA3MzctMC4wNiAwLjExMjUtMC4wOTEgMTQuODk2IDMuODkyMSAyNC4xNDggOS4zMzI2IDI0LjE0OCAxNS4zNTEgMCAxMS44MzQtMzUuNzU1IDIxLjQyNi03OS44NjEgMjEuNDI2bTMzLjc2Mi00MC43NTRjMC4wNDYzLTAuMDI1IDAuMDk4OC0wLjA1MiAwLjE0NjI1LTAuMDc3LTAuMDQ3NSAwLjAyNS0wLjEgMC4wNTItMC4xNDYyNSAwLjA3NyIgZmlsbD0iI2ZlZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDUyMDMiIGQ9Im0zMDUuNTkgOTQ5LjI5Yy00My45MyAwLTc5LjU0MS05LjU1MzctNzkuNTQxLTIxLjM0IDAtNS45OCA5LjE2ODgtMTEuMzg1IDIzLjkzOS0xNS4yNiAwLjAzNjIgMC4wMyAwLjA3NjMgMC4wNjIgMC4xMTI1IDAuMDkxLTE0LjY0NSAzLjg1NjUtMjMuNzMgOS4yMjc2LTIzLjczIDE1LjE2OSAwIDExLjczOSAzNS40NjggMjEuMjU1IDc5LjIyIDIxLjI1NSA0My43NTEgMCA3OS4yMTktOS41MTYyIDc5LjIxOS0yMS4yNTUgMC01Ljk0MTQtOS4wODM4LTExLjMxMi0yMy43My0xNS4xNjkgMC4wMzc1LTAuMDMgMC4wNzYzLTAuMDYxIDAuMTEyNS0wLjA5MSAxNC43NzEgMy44NzU1IDIzLjkzOSA5LjI4MDIgMjMuOTM5IDE1LjI2IDAgMTEuNzg2LTM1LjYxMSAyMS4zNC03OS41NCAyMS4zNG0zMy42MTUtNDAuNTkxYzAuMDQ4Ny0wLjAyNSAwLjA5NzUtMC4wNTEgMC4xNDc1LTAuMDc2LTAuMDUgMC4wMjUtMC4wOTg3IDAuMDUxLTAuMTQ3NSAwLjA3NiIgZmlsbD0iI2ZlZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDUyMDUiIGQ9Im0zMDUuNTkgOTQ5LjJjLTQzLjc1MyAwLTc5LjIyLTkuNTE2MS03OS4yMi0yMS4yNTUgMC01Ljk0MTQgOS4wODUtMTEuMzEyIDIzLjczLTE1LjE2OSAwLjAzODcgMC4wMzEgMC4wNzM4IDAuMDU5IDAuMTEyNSAwLjA5LTE0LjUyMSAzLjgzOTktMjMuNTIyIDkuMTc3My0yMy41MjIgMTUuMDc5IDAgMTEuNjkxIDM1LjMyNSAyMS4xNjkgNzguOSAyMS4xNjlzNzguODk5LTkuNDc3NiA3OC44OTktMjEuMTY5YzAtNS45MDEzLTkuMDAxMi0xMS4yMzktMjMuNTIyLTE1LjA3OSAwLjAzODctMC4wMzEgMC4wNzM3LTAuMDU5IDAuMTEyNS0wLjA5IDE0LjY0NiAzLjg1NjUgMjMuNzMgOS4yMjc2IDIzLjczIDE1LjE2OSAwIDExLjczOS0zNS40NjggMjEuMjU1LTc5LjIxOSAyMS4yNTVtMzMuNDY4LTQwLjQzYzAuMDUxMy0wLjAyNiAwLjA5NjMtMC4wNSAwLjE0NzUtMC4wNzctMC4wNTEyIDAuMDI2LTAuMDk2MiAwLjA1LTAuMTQ3NSAwLjA3NyIgZmlsbD0iI2ZkZmVmZSIvPgogICA8cGF0aCBpZD0icGF0aDUyMDciIGQ9Im0zMDUuNTkgOTQ5LjEyYy00My41NzUgMC03OC45LTkuNDc3Ni03OC45LTIxLjE2OSAwLTUuOTAxNCA5LjAwMTItMTEuMjM5IDIzLjUyMi0xNS4wNzlsMC4xMTM3NSAwLjA5MWMtMTQuMzk1IDMuODIwNy0yMy4zMTUgOS4xMjUtMjMuMzE1IDE0Ljk4NyAwIDExLjY0NCAzNS4xODEgMjEuMDgzIDc4LjU3OSAyMS4wODNzNzguNTc4LTkuNDM5IDc4LjU3OC0yMS4wODNjMC01Ljg2MjItOC45Mi0xMS4xNjYtMjMuMzE1LTE0Ljk4NyAwLjAzNzUtMC4wMjkgMC4wNzc1LTAuMDYyIDAuMTEzNzUtMC4wOTEgMTQuNTIxIDMuODM5OSAyMy41MjIgOS4xNzcyIDIzLjUyMiAxNS4wNzkgMCAxMS42OTEtMzUuMzI0IDIxLjE2OS03OC44OTkgMjEuMTY5bTMzLjMxOS00MC4yNjhjMC4wNDg3LTAuMDI1IDAuMDk4Ny0wLjA1MSAwLjE0ODc1LTAuMDc2LTAuMDUgMC4wMjUtMC4xIDAuMDUxLTAuMTQ4NzUgMC4wNzYiIGZpbGw9IiNmZGZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1MjA5IiBkPSJtMzA1LjU5IDk0OS4wM2MtNDMuMzk4IDAtNzguNTc5LTkuNDM4OS03OC41NzktMjEuMDgyIDAtNS44NjIyIDguOTItMTEuMTY2IDIzLjMxNS0xNC45ODcgMC4wMzg3IDAuMDMxIDAuMDc1IDAuMDYgMC4xMTM3NSAwLjA5MS0xNC4yNzEgMy44MDQyLTIzLjEwOCA5LjA3MjctMjMuMTA4IDE0Ljg5NiAwIDExLjU5NiAzNS4wMzYgMjAuOTk2IDc4LjI1OCAyMC45OTYgNDMuMjIgMCA3OC4yNTgtOS4zOTk5IDc4LjI1OC0yMC45OTYgMC01LjgyMzgtOC44MzYyLTExLjA5Mi0yMy4xMDktMTQuODk2IDAuMDQtMC4wMzEgMC4wNzUtMC4wNiAwLjExMzc1LTAuMDkxIDE0LjM5NSAzLjgyMDcgMjMuMzE1IDkuMTI1IDIzLjMxNSAxNC45ODcgMCAxMS42NDQtMzUuMTggMjEuMDgyLTc4LjU3OCAyMS4wODJtMzMuMTctNDAuMTA1YzAuMDQ4Ny0wLjAyNSAwLjEtMC4wNTEgMC4xNDg3NS0wLjA3Ni0wLjA0ODcgMC4wMjUtMC4xIDAuMDUxLTAuMTQ4NzUgMC4wNzYiIGZpbGw9IiNmZGZkZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1MjExIiBkPSJtMzA1LjU5IDk0OC45NGMtNDMuMjIxIDAtNzguMjU4LTkuMzk5OS03OC4yNTgtMjAuOTk2IDAtNS44MjM3IDguODM2Mi0xMS4wOTIgMjMuMTA4LTE0Ljg5NiAwLjAzNjIgMC4wMjkgMC4wNzc1IDAuMDYyIDAuMTEzNzUgMC4wOTEtMTQuMTQ2IDMuNzg1MS0yMi45MDEgOS4wMjE0LTIyLjkwMSAxNC44MDUgMCAxMS41NDkgMzQuODk0IDIwLjkxIDc3LjkzOCAyMC45MSA0My4wNDIgMCA3Ny45MzYtOS4zNjE0IDc3LjkzNi0yMC45MSAwLTUuNzgzNy04Ljc1NS0xMS4wMi0yMi45MDEtMTQuODA1bDAuMTEzNzUtMC4wOTFjMTQuMjcyIDMuODA0MiAyMy4xMDkgOS4wNzI3IDIzLjEwOSAxNC44OTYgMCAxMS41OTYtMzUuMDM4IDIwLjk5Ni03OC4yNTggMjAuOTk2bTMzLjAyLTM5Ljk0MmMwLjA0ODctMC4wMjUgMC4xMDEyNS0wLjA1MSAwLjE1LTAuMDc2LTAuMDQ4NyAwLjAyNS0wLjEwMTI1IDAuMDUxLTAuMTUgMC4wNzYiIGZpbGw9IiNmZGZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjEzIiBkPSJtMzA1LjU5IDk0OC44NmMtNDMuMDQ0IDAtNzcuOTM4LTkuMzYxNC03Ny45MzgtMjAuOTEgMC01Ljc4MzcgOC43NTUtMTEuMDIgMjIuOTAxLTE0LjgwNSAwLjA0IDAuMDMxIDAuMDc2MiAwLjA2IDAuMTE1IDAuMDktMTQuMDI1IDMuNzY4Ni0yMi42OTUgOC45Njk3LTIyLjY5NSAxNC43MTUgMCAxMS41MDEgMzQuNzUgMjAuODI0IDc3LjYxNiAyMC44MjRzNzcuNjE1LTkuMzIyMyA3Ny42MTUtMjAuODI0YzAtNS43NDUyLTguNjctMTAuOTQ2LTIyLjY5NC0xNC43MTUgMC4wMzg3LTAuMDMgMC4wNzUtMC4wNTkgMC4xMTM3NS0wLjA5IDE0LjE0NiAzLjc4NTEgMjIuOTAxIDkuMDIxNCAyMi45MDEgMTQuODA1IDAgMTEuNTQ5LTM0Ljg5NCAyMC45MS03Ny45MzYgMjAuOTFtMzIuODctMzkuNzhjMC4wNTEyLTAuMDI2IDAuMDk4Ny0wLjA1IDAuMTUtMC4wNzYtMC4wNTEzIDAuMDI2LTAuMDk4OCAwLjA1LTAuMTUgMC4wNzYiIGZpbGw9IiNmZGZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjE1IiBkPSJtMzA1LjU5IDk0OC43N2MtNDIuODY2IDAtNzcuNjE2LTkuMzIyMi03Ny42MTYtMjAuODI0IDAtNS43NDUxIDguNjctMTAuOTQ2IDIyLjY5NS0xNC43MTUgMC4wMzYyIDAuMDMgMC4wNzc1IDAuMDYyIDAuMTE1IDAuMDkxLTEzLjkwMSAzLjc1LTIyLjQ4OSA4LjkxNzUtMjIuNDg5IDE0LjYyNCAwIDExLjQ1NCAzNC42MDYgMjAuNzM3IDc3LjI5NSAyMC43MzdzNzcuMjk1LTkuMjgzNiA3Ny4yOTUtMjAuNzM3YzAtNS43MDYtOC41ODg4LTEwLjg3NC0yMi40ODktMTQuNjI0IDAuMDM2Mi0wLjAyOSAwLjA3NzUtMC4wNjIgMC4xMTUtMC4wOTEgMTQuMDI0IDMuNzY4NyAyMi42OTQgOC45Njk4IDIyLjY5NCAxNC43MTUgMCAxMS41MDItMzQuNzQ5IDIwLjgyNC03Ny42MTUgMjAuODI0bTMyLjcxOS0zOS42MThjMC4wNS0wLjAyNSAwLjEwMTI1LTAuMDUxIDAuMTUxMjUtMC4wNzYtMC4wNSAwLjAyNS0wLjEwMTI1IDAuMDUxLTAuMTUxMjUgMC4wNzYiIGZpbGw9IiNmY2ZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjE3IiBkPSJtMzA1LjU5IDk0OC42OGMtNDIuNjg5IDAtNzcuMjk1LTkuMjgzNy03Ny4yOTUtMjAuNzM3IDAtNS43MDYgOC41ODc1LTEwLjg3NCAyMi40ODktMTQuNjI0IDAuMDM4OCAwLjAzMSAwLjA3NjMgMC4wNiAwLjExNSAwLjA5MS0xMy43NzggMy43MzE1LTIyLjI4NCA4Ljg2NTMtMjIuMjg0IDE0LjUzMyAwIDExLjQwNiAzNC40NjIgMjAuNjUyIDc2Ljk3NSAyMC42NTIgNDIuNTExIDAgNzYuOTc0LTkuMjQ2MSA3Ni45NzQtMjAuNjUyIDAtNS42Njc1LTguNTA2Mi0xMC44MDEtMjIuMjg0LTE0LjUzMyAwLjA0LTAuMDMxIDAuMDc2Mi0wLjA1OSAwLjExNjI1LTAuMDkxIDEzLjkgMy43NSAyMi40ODkgOC45MTc1IDIyLjQ4OSAxNC42MjQgMCAxMS40NTQtMzQuNjA2IDIwLjczNy03Ny4yOTUgMjAuNzM3bTMyLjU2Ni0zOS40NTVjMC4wNTI1LTAuMDI2IDAuMTAxMjUtMC4wNSAwLjE1MjUtMC4wNzYtMC4wNTEyIDAuMDI2LTAuMSAwLjA1LTAuMTUyNSAwLjA3NiIgZmlsbD0iI2ZjZmRmZCIvPgogICA8cGF0aCBpZD0icGF0aDUyMTkiIGQ9Im0zMDUuNTkgOTQ4LjZjLTQyLjUxMyAwLTc2Ljk3NS05LjI0NjEtNzYuOTc1LTIwLjY1MiAwLTUuNjY3NSA4LjUwNjItMTAuODAxIDIyLjI4NC0xNC41MzMgMC4wMzg3IDAuMDMxIDAuMDc2MyAwLjA2IDAuMTE2MjUgMC4wOTEtMTMuNjUxIDMuNzEzOC0yMi4wNzkgOC44MTM4LTIyLjA3OSAxNC40NDEgMCAxMS4zNTkgMzQuMzE5IDIwLjU2NiA3Ni42NTQgMjAuNTY2IDQyLjMzNCAwIDc2LjY1NC05LjIwNzUgNzYuNjU0LTIwLjU2NiAwLTUuNjI3NS04LjQyNzUtMTAuNzI4LTIyLjA3OS0xNC40NDEgMC4wMzg3LTAuMDMxIDAuMDc2My0wLjA2IDAuMTE1LTAuMDkxIDEzLjc3OCAzLjczMTUgMjIuMjg0IDguODY1MiAyMi4yODQgMTQuNTMzIDAgMTEuNDA2LTM0LjQ2MiAyMC42NTItNzYuOTc0IDIwLjY1Mm0zMi40MTQtMzkuMjk0YzAuMDUtMC4wMjUgMC4xMDM3NS0wLjA1MSAwLjE1MjUtMC4wNzctMC4wNDg3IDAuMDI1LTAuMTAyNSAwLjA1MS0wLjE1MjUgMC4wNzciIGZpbGw9IiNmY2ZjZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjIxIiBkPSJtMzA1LjU5IDk0OC41MWMtNDIuMzM1IDAtNzYuNjU0LTkuMjA3NS03Ni42NTQtMjAuNTY2IDAtNS42Mjc1IDguNDI3NS0xMC43MjggMjIuMDc5LTE0LjQ0MSAwLjAzNjIgMC4wMjkgMC4wNzg3IDAuMDYyIDAuMTE2MjUgMC4wOTEtMTMuNTMgMy42OTU0LTIxLjg3NSA4Ljc2MTMtMjEuODc1IDE0LjM1IDAgMTEuMzExIDM0LjE3NiAyMC40OCA3Ni4zMzQgMjAuNDhzNzYuMzMyLTkuMTY4OSA3Ni4zMzItMjAuNDhjMC01LjU4ODktOC4zNDUtMTAuNjU1LTIxLjg3NC0xNC4zNSAwLjAzNzUtMC4wMjkgMC4wNzg4LTAuMDYyIDAuMTE2MjUtMC4wOTEgMTMuNjUxIDMuNzEzOSAyMi4wNzkgOC44MTM5IDIyLjA3OSAxNC40NDEgMCAxMS4zNTktMzQuMzIgMjAuNTY2LTc2LjY1NCAyMC41NjZtMzIuMjYxLTM5LjEzM2MwLjA1MTItMC4wMjUgMC4xMDEyNS0wLjA1IDAuMTUyNS0wLjA3NS0wLjA1MTIgMC4wMjUtMC4xMDEyNSAwLjA1LTAuMTUyNSAwLjA3NSIgZmlsbD0iI2ZjZmNmYyIvPgogICA8cGF0aCBpZD0icGF0aDUyMjMiIGQ9Im0zMDUuNTkgOTQ4LjQzYy00Mi4xNTggMC03Ni4zMzQtOS4xNjg5LTc2LjMzNC0yMC40OCAwLTUuNTg4OSA4LjM0NS0xMC42NTUgMjEuODc1LTE0LjM1IDAuMDM4NyAwLjAzIDAuMDc2MyAwLjA2IDAuMTE2MjUgMC4wOTEtMTMuNDA1IDMuNjc2Mi0yMS42NyA4LjcwOS0yMS42NyAxNC4yNTkgMCAxMS4yNjQgMzQuMDMxIDIwLjM5NCA3Ni4wMTMgMjAuMzk0IDQxLjk4IDAgNzYuMDExLTkuMTI5OSA3Ni4wMTEtMjAuMzk0IDAtNS41NDk3LTguMjYzOC0xMC41ODItMjEuNjY5LTE0LjI1OSAwLjAzODctMC4wMzEgMC4wNzc1LTAuMDYxIDAuMTE2MjUtMC4wOTEgMTMuNTI5IDMuNjk1NCAyMS44NzQgOC43NjEyIDIxLjg3NCAxNC4zNSAwIDExLjMxMS0zNC4xNzUgMjAuNDgtNzYuMzMyIDIwLjQ4bTMyLjEwOC0zOC45N2MwLjA1MTMtMC4wMjYgMC4xMDI1LTAuMDUgMC4xNTM3NS0wLjA3Ni0wLjA1MTIgMC4wMjYtMC4xMDI1IDAuMDUtMC4xNTM3NSAwLjA3NiIgZmlsbD0iI2ZiZmNmYyIvPgogICA8cGF0aCBpZD0icGF0aDUyMjUiIGQ9Im0zMDUuNTkgOTQ4LjM0Yy00MS45ODEgMC03Ni4wMTMtOS4xMjk5LTc2LjAxMy0yMC4zOTQgMC01LjU0OTcgOC4yNjUtMTAuNTgyIDIxLjY3LTE0LjI1OSAwLjAzODcgMC4wMyAwLjA3NzUgMC4wNiAwLjExNjI1IDAuMDktMTMuMjg0IDMuNjYwMi0yMS40NjUgOC42NTc3LTIxLjQ2NSAxNC4xNjkgMCAxMS4yMTYgMzMuODg4IDIwLjMwOCA3NS42OTEgMjAuMzA4IDQxLjgwMiAwIDc1LjY5MS05LjA5MTQgNzUuNjkxLTIwLjMwOCAwLTUuNTExMy04LjE4MjUtMTAuNTA5LTIxLjQ2Ni0xNC4xNjkgMC4wNC0wLjAzIDAuMDc3NS0wLjA2IDAuMTE3NS0wLjA5IDEzLjQwNSAzLjY3NjIgMjEuNjY5IDguNzA5IDIxLjY2OSAxNC4yNTkgMCAxMS4yNjQtMzQuMDMxIDIwLjM5NC03Ni4wMTEgMjAuMzk0bTMxLjk1Mi0zOC44MDljMC4wNTEzLTAuMDI1IDAuMTAyNS0wLjA1IDAuMTU1LTAuMDc1LTAuMDUyNSAwLjAyNS0wLjEwMzc1IDAuMDUtMC4xNTUgMC4wNzUiIGZpbGw9IiNmYmZjZmMiLz4KICAgPHBhdGggaWQ9InBhdGg1MjI3IiBkPSJtMzA1LjU5IDk0OC4yNWMtNDEuODA0IDAtNzUuNjkxLTkuMDkxMy03NS42OTEtMjAuMzA4IDAtNS41MTEyIDguMTgxMi0xMC41MDkgMjEuNDY1LTE0LjE2OSAwLjA0IDAuMDMxIDAuMDc4NyAwLjA2MiAwLjExNzUgMC4wOTEtMTMuMTYxIDMuNjQxMS0yMS4yNjIgOC42MDUtMjEuMjYyIDE0LjA3OCAwIDExLjE2OSAzMy43NDUgMjAuMjIxIDc1LjM3MSAyMC4yMjFzNzUuMzctOS4wNTIyIDc1LjM3LTIwLjIyMWMwLTUuNDcyNi04LjEwMTItMTAuNDM2LTIxLjI2Mi0xNC4wNzggMC4wNC0wLjAzIDAuMDc4OC0wLjA2IDAuMTE3NS0wLjA5MSAxMy4yODQgMy42NjAzIDIxLjQ2NiA4LjY1NzggMjEuNDY2IDE0LjE2OSAwIDExLjIxNi0zMy44ODkgMjAuMzA4LTc1LjY5MSAyMC4zMDhtMzEuNzk4LTM4LjY0NmMwLjA1MTMtMC4wMjUgMC4xMDM3NS0wLjA1MSAwLjE1NS0wLjA3Ni0wLjA1MTIgMC4wMjUtMC4xMDM3NSAwLjA1MS0wLjE1NSAwLjA3NiIgZmlsbD0iI2ZiZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDUyMjkiIGQ9Im0zMDUuNTkgOTQ4LjE3Yy00MS42MjYgMC03NS4zNzEtOS4wNTIyLTc1LjM3MS0yMC4yMjEgMC01LjQ3MjYgOC4xMDEyLTEwLjQzNiAyMS4yNjItMTQuMDc4IDAuMDQgMC4wMzEgMC4wNzg3IDAuMDYyIDAuMTE3NSAwLjA5MS0xMy4wMzggMy42MjI2LTIxLjA1OSA4LjU1MjctMjEuMDU5IDEzLjk4NiAwIDExLjEyIDMzLjYwMSAyMC4xMzYgNzUuMDUgMjAuMTM2czc1LjA0OS05LjAxNjEgNzUuMDQ5LTIwLjEzNmMwLTUuNDMzNy04LjAyLTEwLjM2NC0yMS4wNTktMTMuOTg2IDAuMDQtMC4wMyAwLjA3ODgtMC4wNiAwLjExNzUtMC4wOTEgMTMuMTYxIDMuNjQxMSAyMS4yNjIgOC42MDUgMjEuMjYyIDE0LjA3OCAwIDExLjE2OS0zMy43NDQgMjAuMjIxLTc1LjM3IDIwLjIyMW0zMS42NDEtMzguNDg1YzAuMDUxMy0wLjAyNSAwLjEwMzc1LTAuMDUgMC4xNTYyNS0wLjA3NS0wLjA1MjUgMC4wMjUtMC4xMDUgMC4wNS0wLjE1NjI1IDAuMDc1IiBmaWxsPSIjZmJmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTIzMSIgZD0ibTMwNS41OSA5NDguMDhjLTQxLjQ0OSAwLTc1LjA1LTkuMDE2MS03NS4wNS0yMC4xMzYgMC01LjQzMzYgOC4wMjEyLTEwLjM2NCAyMS4wNTktMTMuOTg2IDAuMDQgMC4wMyAwLjA3ODggMC4wNjIgMC4xMTg3NSAwLjA5MS0xMi45MTYgMy42MDQtMjAuODU4IDguNS0yMC44NTggMTMuODk1IDAgMTEuMDcyIDMzLjQ1OCAyMC4wNSA3NC43MyAyMC4wNSA0MS4yNzEgMCA3NC43MjktOC45Nzc1IDc0LjcyOS0yMC4wNSAwLTUuMzk1LTcuOTQtMTAuMjkxLTIwLjg1OC0xMy44OTUgMC4wNC0wLjAzIDAuMDc4Ny0wLjA2MSAwLjExODc1LTAuMDkxIDEzLjAzOSAzLjYyMjcgMjEuMDU5IDguNTUyOCAyMS4wNTkgMTMuOTg2IDAgMTEuMTItMzMuNiAyMC4xMzYtNzUuMDQ5IDIwLjEzNm0zMS40ODQtMzguMzI1bDAuMTU3NS0wLjA3NS0wLjE1NzUgMC4wNzUiIGZpbGw9IiNmYWZiZmIiLz4KICAgPHBhdGggaWQ9InBhdGg1MjMzIiBkPSJtMzA1LjU5IDk0OGMtNDEuMjczIDAtNzQuNzMtOC45Nzc1LTc0LjczLTIwLjA1IDAtNS4zOTUgNy45NDEyLTEwLjI5MSAyMC44NTgtMTMuODk1IDAuMDQgMC4wMyAwLjA3ODggMC4wNjIgMC4xMTg3NSAwLjA5MS0xMi43OTIgMy41ODUtMjAuNjU1IDguNDQ4OC0yMC42NTUgMTMuODA0IDAgMTEuMDI1IDMzLjMxNCAxOS45NjQgNzQuNDA5IDE5Ljk2NCA0MS4wOTQgMCA3NC40MDgtOC45MzkgNzQuNDA4LTE5Ljk2NCAwLTUuMzU1LTcuODYxMi0xMC4yMTktMjAuNjU0LTEzLjgwNCAwLjAzODctMC4wMyAwLjA3ODctMC4wNjIgMC4xMTc1LTAuMDkxIDEyLjkxOCAzLjYwNCAyMC44NTggOC41IDIwLjg1OCAxMy44OTUgMCAxMS4wNzItMzMuNDU4IDIwLjA1LTc0LjcyOSAyMC4wNW0zMS4zMjYtMzguMTYyYzAuMDUyNS0wLjAyNiAwLjEwNS0wLjA1MSAwLjE1NzUtMC4wNzctMC4wNTI1IDAuMDI1LTAuMTA1IDAuMDUxLTAuMTU3NSAwLjA3NyIgZmlsbD0iI2ZhZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDUyMzUiIGQ9Im0zMDUuNTkgOTQ3LjkxYy00MS4wOTUgMC03NC40MDktOC45MzktNzQuNDA5LTE5Ljk2NCAwLTUuMzU1IDcuODYyNS0xMC4yMTkgMjAuNjU1LTEzLjgwNCAwLjA0IDAuMDMgMC4wNzg4IDAuMDYxIDAuMTE4NzUgMC4wOTEtMTIuNjcyIDMuNTY1OC0yMC40NTIgOC4zOTYtMjAuNDUyIDEzLjcxMiAwIDEwLjk3OCAzMy4xNyAxOS44NzggNzQuMDg4IDE5Ljg3OHM3NC4wODgtOC45IDc0LjA4OC0xOS44NzhjMC01LjMxNjMtNy43ODEyLTEwLjE0Ni0yMC40NTQtMTMuNzEyIDAuMDQtMC4wMyAwLjA4LTAuMDYyIDAuMTItMC4wOTEgMTIuNzkyIDMuNTg1IDIwLjY1NCA4LjQ0ODcgMjAuNjU0IDEzLjgwNCAwIDExLjAyNS0zMy4zMTQgMTkuOTY0LTc0LjQwOCAxOS45NjRtMzEuMTY4LTM4LjAwMmMwLjA1MjUtMC4wMjUgMC4xMDYyNS0wLjA1MSAwLjE1ODc1LTAuMDc1LTAuMDUyNSAwLjAyMy0wLjEwNjI1IDAuMDUtMC4xNTg3NSAwLjA3NSIgZmlsbD0iI2ZhZmFmYSIvPgogICA8cGF0aCBpZD0icGF0aDUyMzciIGQ9Im0zMDUuNTkgOTQ3LjgyYy00MC45MTggMC03NC4wODgtOC45LTc0LjA4OC0xOS44NzggMC01LjMxNjQgNy43OC0xMC4xNDYgMjAuNDUyLTEzLjcxMiAwLjA0IDAuMDMgMC4wOCAwLjA2MSAwLjEyIDAuMDkxLTEyLjU1IDMuNTQ3NC0yMC4yNTIgOC4zNDM4LTIwLjI1MiAxMy42MjEgMCAxMC45MyAzMy4wMjggMTkuNzkxIDczLjc2OCAxOS43OTFzNzMuNzY2LTguODYwOSA3My43NjYtMTkuNzkxYzAtNS4yNzc0LTcuNzAxMi0xMC4wNzQtMjAuMjUxLTEzLjYyMSAwLjAzODctMC4wMyAwLjA3ODgtMC4wNjIgMC4xMTg3NS0wLjA5MSAxMi42NzIgMy41NjU5IDIwLjQ1NCA4LjM5NiAyMC40NTQgMTMuNzEyIDAgMTAuOTc4LTMzLjE3IDE5Ljg3OC03NC4wODggMTkuODc4bTMxLjAwOS0zNy44NGMwLjA1MzctMC4wMjYgMC4xMDUtMC4wNSAwLjE1ODc1LTAuMDc1LTAuMDUzNyAwLjAyNS0wLjEwNSAwLjA0OS0wLjE1ODc1IDAuMDc1IiBmaWxsPSIjZmFmYWZhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTIzOSIgZD0ibTMwNS41OSA5NDcuNzRjLTQwLjc0IDAtNzMuNzY4LTguODYwOS03My43NjgtMTkuNzkxIDAtNS4yNzc0IDcuNzAyNS0xMC4wNzQgMjAuMjUyLTEzLjYyMSAwLjA0IDAuMDMgMC4wOCAwLjA2MSAwLjEyIDAuMDkxLTEyLjQzMSAzLjUyODItMjAuMDUxIDguMjkxLTIwLjA1MSAxMy41MyAwIDEwLjg4MiAzMi44ODIgMTkuNzA1IDczLjQ0NiAxOS43MDUgNDAuNTYyIDAgNzMuNDQ1LTguODIyNyA3My40NDUtMTkuNzA1IDAtNS4yMzg3LTcuNjE4OC0xMC4wMDItMjAuMDUtMTMuNTMgMC4wMzg4LTAuMDMgMC4wOC0wLjA2MiAwLjEyLTAuMDkxIDEyLjU1IDMuNTQ3NCAyMC4yNTEgOC4zNDM3IDIwLjI1MSAxMy42MjEgMCAxMC45My0zMy4wMjYgMTkuNzkxLTczLjc2NiAxOS43OTFtMzAuODQ5LTM3LjY3OWMwLjA1MTItMC4wMjUgMC4xMDc1LTAuMDUxIDAuMTYtMC4wNzUtMC4wNTI1IDAuMDIzLTAuMTA4NzUgMC4wNS0wLjE2IDAuMDc1IiBmaWxsPSIjZjlmYWZhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTI0MSIgZD0ibTMwNS41OSA5NDcuNjVjLTQwLjU2NCAwLTczLjQ0Ni04LjgyMjctNzMuNDQ2LTE5LjcwNSAwLTUuMjM4NyA3LjYyLTEwLjAwMiAyMC4wNTEtMTMuNTMgMC4wNCAwLjAzIDAuMDggMC4wNjEgMC4xMiAwLjA5MS0xMi4zMSAzLjUxMDItMTkuODUxIDguMjM4Ny0xOS44NTEgMTMuNDM5IDAgMTAuODM1IDMyLjc0IDE5LjYxOSA3My4xMjYgMTkuNjE5IDQwLjM4NSAwIDczLjEyNS04Ljc4MzYgNzMuMTI1LTE5LjYxOSAwLTUuMjAwMy03LjU0LTkuOTI4OC0xOS44NTEtMTMuNDM5IDAuMDQtMC4wMyAwLjA4MTMtMC4wNjEgMC4xMjEyNS0wLjA5MSAxMi40MzEgMy41MjgyIDIwLjA1IDguMjkxIDIwLjA1IDEzLjUzIDAgMTAuODgyLTMyLjg4MiAxOS43MDUtNzMuNDQ1IDE5LjcwNW0zMC42ODgtMzcuNTE4YzAuMDU1LTAuMDI2IDAuMTA2MjUtMC4wNSAwLjE2MTI1LTAuMDc1LTAuMDU1IDAuMDI2LTAuMTA2MjUgMC4wNDktMC4xNjEyNSAwLjA3NSIgZmlsbD0iI2Y5ZjlmOSIvPgogICA8cGF0aCBpZD0icGF0aDUyNDMiIGQ9Im0zMDUuNTkgOTQ3LjU3Yy00MC4zODYgMC03My4xMjYtOC43ODM3LTczLjEyNi0xOS42MTkgMC01LjIwMDIgNy41NDEyLTkuOTI4NyAxOS44NTEtMTMuNDM5IDAuMDQgMC4wMyAwLjA4MTMgMC4wNjIgMC4xMjEyNSAwLjA5MS0xMi4xODggMy40OTExLTE5LjY1MSA4LjE4NjUtMTkuNjUxIDEzLjM0OCAwIDEwLjc4OCAzMi41OTYgMTkuNTM0IDcyLjgwNSAxOS41MzRzNzIuODA0LTguNzQ2IDcyLjgwNC0xOS41MzRjMC01LjE2MTEtNy40NjM4LTkuODU2NS0xOS42NS0xMy4zNDggMC4wNC0wLjAzIDAuMDgtMC4wNjEgMC4xMi0wLjA5MSAxMi4zMTEgMy41MTAzIDE5Ljg1MSA4LjIzODggMTkuODUxIDEzLjQzOSAwIDEwLjgzNS0zMi43NCAxOS42MTktNzMuMTI1IDE5LjYxOW0zMC41MjYtMzcuMzU3YzAuMDUzOC0wLjAyNSAwLjEwNjI1LTAuMDQ5IDAuMTYxMjUtMC4wNzQtMC4wNTUgMC4wMjUtMC4xMDc1IDAuMDQ5LTAuMTYxMjUgMC4wNzQiIGZpbGw9IiNmOWY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg1MjQ1IiBkPSJtMzA1LjU5IDk0Ny40OGMtNDAuMjA5IDAtNzIuODA1LTguNzQ2MS03Mi44MDUtMTkuNTM0IDAtNS4xNjExIDcuNDYzOC05Ljg1NjUgMTkuNjUxLTEzLjM0OCAwLjA0MTIgMC4wMzEgMC4wNzg4IDAuMDU5IDAuMTIxMjUgMC4wOTEtMTIuMDY4IDMuNDcyNi0xOS40NTEgOC4xMzM5LTE5LjQ1MSAxMy4yNTYgMCAxMC43NCAzMi40NTEgMTkuNDQ4IDcyLjQ4NCAxOS40NDggNDAuMDMxIDAgNzIuNDg0LTguNzA3NSA3Mi40ODQtMTkuNDQ4IDAtNS4xMjI1LTcuMzgzOC05Ljc4MzgtMTkuNDUyLTEzLjI1NiAwLjA0MjUtMC4wMzIgMC4wOC0wLjA2IDAuMTIyNS0wLjA5MSAxMi4xODYgMy40OTExIDE5LjY1IDguMTg2NSAxOS42NSAxMy4zNDggMCAxMC43ODgtMzIuNTk1IDE5LjUzNC03Mi44MDQgMTkuNTM0bTMwLjM2Mi0zNy4xOTdjMC4wNTUtMC4wMjUgMC4xMDg3NS0wLjA1IDAuMTYzNzUtMC4wNzUtMC4wNTUgMC4wMjUtMC4xMDg3NSAwLjA1LTAuMTYzNzUgMC4wNzUiIGZpbGw9IiNmOGY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg1MjQ3IiBkPSJtMzA1LjU5IDk0Ny4zOWMtNDAuMDMzIDAtNzIuNDg0LTguNzA3NS03Mi40ODQtMTkuNDQ4IDAtNS4xMjI1IDcuMzgzOC05Ljc4MzcgMTkuNDUxLTEzLjI1NiAwLjA0IDAuMDMgMC4wODEzIDAuMDYyIDAuMTIxMjUgMC4wOTEtMTEuOTQ5IDMuNDUzNy0xOS4yNTIgOC4wOC0xOS4yNTIgMTMuMTY1IDAgMTAuNjkyIDMyLjMwOSAxOS4zNjEgNzIuMTY0IDE5LjM2MSAzOS44NTQgMCA3Mi4xNjItOC42NjkgNzIuMTYyLTE5LjM2MSAwLTUuMDg1LTcuMzAzOC05LjcxMTMtMTkuMjUyLTEzLjE2NSAwLjA0LTAuMDMgMC4wODI1LTAuMDYxIDAuMTIxMjUtMC4wOTEgMTIuMDY5IDMuNDcyNyAxOS40NTIgOC4xMzM5IDE5LjQ1MiAxMy4yNTYgMCAxMC43NC0zMi40NTIgMTkuNDQ4LTcyLjQ4NCAxOS40NDhtMzAuMi0zNy4wMzdjMC4wNTI1LTAuMDIzIDAuMTEtMC4wNTEgMC4xNjI1LTAuMDc1LTAuMDUyNSAwLjAyNC0wLjExIDAuMDUxLTAuMTYyNSAwLjA3NSIgZmlsbD0iI2Y4ZjhmOCIvPgogICA8cGF0aCBpZD0icGF0aDUyNDkiIGQ9Im0zMDUuNTkgOTQ3LjMxYy0zOS44NTUgMC03Mi4xNjQtOC42Njg5LTcyLjE2NC0xOS4zNjEgMC01LjA4NSA3LjMwMzgtOS43MTE0IDE5LjI1Mi0xMy4xNjUgMC4wNCAwLjAyOSAwLjA4MjUgMC4wNjIgMC4xMjI1IDAuMDkxLTExLjgyOCAzLjQzNTEtMTkuMDU0IDguMDI3NC0xOS4wNTQgMTMuMDc0IDAgMTAuNjQ1IDMyLjE2NSAxOS4yNzUgNzEuODQzIDE5LjI3NXM3MS44NDEtOC42Mjk5IDcxLjg0MS0xOS4yNzVjMC01LjA0NjQtNy4yMjUtOS42Mzg3LTE5LjA1NC0xMy4wNzQgMC4wNC0wLjAzIDAuMDgyNS0wLjA2MiAwLjEyMjUtMC4wOTEgMTEuOTQ5IDMuNDUzNiAxOS4yNTIgOC4wOCAxOS4yNTIgMTMuMTY1IDAgMTAuNjkyLTMyLjMwOSAxOS4zNjEtNzIuMTYyIDE5LjM2MW0zMC4wMzUtMzYuODc2YzAuMDU1LTAuMDI0IDAuMTEtMC4wNDkgMC4xNjUtMC4wNzQtMC4wNTUgMC4wMjUtMC4xMSAwLjA1LTAuMTY1IDAuMDc0IiBmaWxsPSIjZjhmOGY4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTI1MSIgZD0ibTMwNS41OSA5NDcuMjJjLTM5LjY3OCAwLTcxLjg0My04LjYyOTktNzEuODQzLTE5LjI3NSAwLTUuMDQ2NCA3LjIyNjItOS42Mzg2IDE5LjA1NC0xMy4wNzQgMC4wNDI1IDAuMDMxIDAuMDgxMiAwLjA1OSAwLjEyMjUgMC4wOTEtMTEuNzA4IDMuNDE2LTE4Ljg1NSA3Ljk3NS0xOC44NTUgMTIuOTgyIDAgMTAuNTk4IDMyLjAyMSAxOS4xODkgNzEuNTIxIDE5LjE4OXM3MS41MjEtOC41OTEzIDcxLjUyMS0xOS4xODljMC01LjAwNzMtNy4xNDg4LTkuNTY2My0xOC44NTYtMTIuOTgyIDAuMDQyNS0wLjAzMyAwLjA4MTMtMC4wNiAwLjEyMjUtMC4wOTEgMTEuODI5IDMuNDM1MSAxOS4wNTQgOC4wMjczIDE5LjA1NCAxMy4wNzQgMCAxMC42NDUtMzIuMTY0IDE5LjI3NS03MS44NDEgMTkuMjc1bTI5Ljg3LTM2LjcxNWMwLjA1NzUtMC4wMjYgMC4xMDg3NS0wLjA0OSAwLjE2NS0wLjA3NS0wLjA1NjMgMC4wMjYtMC4xMDc1IDAuMDQ5LTAuMTY1IDAuMDc1IiBmaWxsPSIjZjdmOGY4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTI1MyIgZD0ibTMwNS41OSA5NDcuMTRjLTM5LjUgMC03MS41MjEtOC41OTE0LTcxLjUyMS0xOS4xODkgMC01LjAwNzQgNy4xNDc1LTkuNTY2NCAxOC44NTUtMTIuOTgyIDAuMDQgMC4wMjkgMC4wODM3IDAuMDYxIDAuMTIzNzUgMC4wOTEtMTEuNTg5IDMuMzk3NS0xOC42NTkgNy45MjI0LTE4LjY1OSAxMi44OTEgMCAxMC41NSAzMS44NzggMTkuMTAyIDcxLjIwMSAxOS4xMDIgMzkuMzIyIDAgNzEuMi04LjU1MjggNzEuMi0xOS4xMDIgMC00Ljk2ODgtNy4wNy05LjQ5MzctMTguNjU4LTEyLjg5MSAwLjA0LTAuMDMgMC4wODI1LTAuMDYyIDAuMTIyNS0wLjA5MSAxMS43MDggMy40MTYgMTguODU2IDcuOTc1IDE4Ljg1NiAxMi45ODIgMCAxMC41OTgtMzIuMDIxIDE5LjE4OS03MS41MjEgMTkuMTg5bTI5LjcwNC0zNi41NTVjMC4wNTUtMC4wMjQgMC4xMTEyNS0wLjA0OSAwLjE2NjI1LTAuMDc0LTAuMDU1IDAuMDI1LTAuMTExMjUgMC4wNS0wLjE2NjI1IDAuMDc0IiBmaWxsPSIjZjdmN2Y3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTI1NSIgZD0ibTMwNS41OSA5NDcuMDVjLTM5LjMyNCAwLTcxLjIwMS04LjU1MjgtNzEuMjAxLTE5LjEwMyAwLTQuOTY4NyA3LjA3LTkuNDkzNiAxOC42NTktMTIuODkxIDAuMDQyNSAwLjAzMSAwLjA4MTMgMC4wNiAwLjEyMzc1IDAuMDkxLTExLjQ3IDMuMzc4NC0xOC40NjEgNy44Njk2LTE4LjQ2MSAxMi44IDAgMTAuNTAyIDMxLjczNCAxOS4wMTYgNzAuODggMTkuMDE2czcwLjg4LTguNTEzNyA3MC44OC0xOS4wMTZjMC00LjkzMDEtNi45OTI1LTkuNDIxMy0xOC40NjEtMTIuOCAwLjA0MjUtMC4wMzEgMC4wODEzLTAuMDYgMC4xMjM3NS0wLjA5MSAxMS41ODggMy4zOTc1IDE4LjY1OCA3LjkyMjQgMTguNjU4IDEyLjg5MSAwIDEwLjU1LTMxLjg3OCAxOS4xMDMtNzEuMiAxOS4xMDNtMjkuNTM4LTM2LjM5NGMwLjA1NS0wLjAyNSAwLjExMTI1LTAuMDUgMC4xNjYyNS0wLjA3NS0wLjA1NSAwLjAyNS0wLjExMTI1IDAuMDUtMC4xNjYyNSAwLjA3NSIgZmlsbD0iI2Y2ZjdmNyIvPgogICA8cGF0aCBpZD0icGF0aDUyNTciIGQ9Im0zMDUuNTkgOTQ2Ljk2Yy0zOS4xNDYgMC03MC44OC04LjUxMzYtNzAuODgtMTkuMDE2IDAtNC45MzAxIDYuOTkxMi05LjQyMTQgMTguNDYxLTEyLjggMC4wNCAwLjAyOCAwLjA4MzcgMC4wNjEgMC4xMjM3NSAwLjA5MS0xMS4zNDkgMy4zNTg4LTE4LjI2NSA3LjgxNzgtMTguMjY1IDEyLjcwOSAwIDEwLjQ1NSAzMS41OTEgMTguOTMxIDcwLjU2IDE4LjkzMXM3MC41NTktOC40NzYgNzAuNTU5LTE4LjkzMWMwLTQuODkxMi02LjkxNS05LjM1MDItMTguMjY1LTEyLjcwOSAwLjA0MTItMC4wMyAwLjA4NS0wLjA2MiAwLjEyNS0wLjA5MSAxMS40NjkgMy4zNzgzIDE4LjQ2MSA3Ljg2OTYgMTguNDYxIDEyLjggMCAxMC41MDItMzEuNzM0IDE5LjAxNi03MC44OCAxOS4wMTZtMjkuMzctMzYuMjMzYzAuMDU2My0wLjAyNSAwLjExLTAuMDQ5IDAuMTY3NS0wLjA3NC0wLjA1NzUgMC4wMjUtMC4xMTEyNSAwLjA0OS0wLjE2NzUgMC4wNzQiIGZpbGw9IiNmNmY2ZjciLz4KICAgPHBhdGggaWQ9InBhdGg1MjU5IiBkPSJtMzA1LjU5IDk0Ni44OGMtMzguOTY5IDAtNzAuNTYtOC40NzYxLTcwLjU2LTE4LjkzMSAwLTQuODkxMSA2LjkxNjItOS4zNTAxIDE4LjI2NS0xMi43MDkgMC4wNDI1IDAuMDMxIDAuMDgyNSAwLjA2IDAuMTI1IDAuMDkxLTExLjIzMiAzLjM0MDMtMTguMDY5IDcuNzY1MS0xOC4wNjkgMTIuNjE4IDAgMTAuNDA4IDMxLjQ0NiAxOC44NDUgNzAuMjM5IDE4Ljg0NSAzOC43OTEgMCA3MC4yMzgtOC40Mzc0IDcwLjIzOC0xOC44NDUgMC00Ljg1MjUtNi44MzYyLTkuMjc3My0xOC4wNjgtMTIuNjE4IDAuMDQyNS0wLjAzMSAwLjA4MTMtMC4wNiAwLjEyMzc1LTAuMDkxIDExLjM1IDMuMzU4OSAxOC4yNjUgNy44MTc5IDE4LjI2NSAxMi43MDkgMCAxMC40NTUtMzEuNTkgMTguOTMxLTcwLjU1OSAxOC45MzFtMjkuMjAxLTM2LjA3NWMwLjA1NS0wLjAyNCAwLjExMzc1LTAuMDUgMC4xNjg3NS0wLjA3NC0wLjA1NSAwLjAyMy0wLjExMzc1IDAuMDUtMC4xNjg3NSAwLjA3NCIgZmlsbD0iI2Y2ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDUyNjEiIGQ9Im0zMDUuNTkgOTQ2Ljc5Yy0zOC43OTMgMC03MC4yMzktOC40Mzc0LTcwLjIzOS0xOC44NDUgMC00Ljg1MjUgNi44MzYyLTkuMjc3NCAxOC4wNjktMTIuNjE4IDAuMDQgMC4wMjkgMC4wODM4IDAuMDYyIDAuMTI1IDAuMDkxLTExLjExNCAzLjMyMTMtMTcuODcyIDcuNzExNC0xNy44NzIgMTIuNTI2IDAgMTAuMzYgMzEuMzAyIDE4Ljc1OSA2OS45MTggMTguNzU5IDM4LjYxNCAwIDY5LjkxOC04LjM5OSA2OS45MTgtMTguNzU5IDAtNC44MTUtNi43Ni05LjIwNTEtMTcuODcyLTEyLjUyNiAwLjA0LTAuMDMgMC4wODUtMC4wNjIgMC4xMjUtMC4wOTEgMTEuMjMxIDMuMzQwMiAxOC4wNjggNy43NjUxIDE4LjA2OCAxMi42MTggMCAxMC40MDgtMzEuNDQ2IDE4Ljg0NS03MC4yMzggMTguODQ1bTI5LjAzMS0zNS45MTVjMC4wNTc1LTAuMDI1IDAuMTEyNS0wLjA0OSAwLjE3LTAuMDc0LTAuMDU3NSAwLjAyNS0wLjExMjUgMC4wNDgtMC4xNyAwLjA3NCIgZmlsbD0iI2Y1ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDUyNjMiIGQ9Im0zMDUuNTkgOTQ2LjcxYy0zOC42MTUgMC02OS45MTgtOC4zOTg5LTY5LjkxOC0xOC43NTkgMC00LjgxNSA2Ljc1ODgtOS4yMDUxIDE3Ljg3Mi0xMi41MjYgMC4wNDI1IDAuMDMxIDAuMDgyNSAwLjA2IDAuMTI1IDAuMDkxLTEwLjk5NSAzLjMwMjctMTcuNjc4IDcuNjU4Ny0xNy42NzggMTIuNDM1IDAgMTAuMzEyIDMxLjE2IDE4LjY3MiA2OS41OTggMTguNjcyczY5LjU5Ni04LjM1OTkgNjkuNTk2LTE4LjY3MmMwLTQuNzc2My02LjY4MjUtOS4xMzIzLTE3LjY3OC0xMi40MzUgMC4wNDI1LTAuMDMxIDAuMDgzOC0wLjA2IDAuMTI2MjUtMC4wOTEgMTEuMTEyIDMuMzIxMyAxNy44NzIgNy43MTE0IDE3Ljg3MiAxMi41MjYgMCAxMC4zNi0zMS4zMDQgMTguNzU5LTY5LjkxOCAxOC43NTltMjguODYxLTM1Ljc1NWMwLjA1NjItMC4wMjUgMC4xMTI1LTAuMDQ5IDAuMTctMC4wNzQtMC4wNTc1IDAuMDI1LTAuMTEzNzUgMC4wNDgtMC4xNyAwLjA3NCIgZmlsbD0iI2Y1ZjVmNSIvPgogICA8cGF0aCBpZD0icGF0aDUyNjUiIGQ9Im0zMDUuNTkgOTQ2LjYyYy0zOC40MzggMC02OS41OTgtOC4zNTk5LTY5LjU5OC0xOC42NzIgMC00Ljc3NjQgNi42ODI1LTkuMTMyNCAxNy42NzgtMTIuNDM1IDAuMDQyNSAwLjAzMSAwLjA4MzggMC4wNiAwLjEyNjI1IDAuMDkxLTEwLjg3OCAzLjI4MjgtMTcuNDgyIDcuNjA2NS0xNy40ODIgMTIuMzQ0IDAgMTAuMjY1IDMxLjAxNiAxOC41ODYgNjkuMjc2IDE4LjU4NnM2OS4yNzYtOC4zMjEzIDY5LjI3Ni0xOC41ODZjMC00LjczNzMtNi42MDYyLTkuMDYxLTE3LjQ4NC0xMi4zNDQgMC4wNDM3LTAuMDMxIDAuMDgzOC0wLjA2IDAuMTI2MjUtMC4wOTEgMTAuOTk1IDMuMzAyNiAxNy42NzggNy42NTg2IDE3LjY3OCAxMi40MzUgMCAxMC4zMTItMzEuMTU5IDE4LjY3Mi02OS41OTYgMTguNjcybTI4LjY4OS0zNS41OTVjMC4wNTUtMC4wMjQgMC4xMTYyNS0wLjA1IDAuMTcyNS0wLjA3NC0wLjA1NjMgMC4wMjQtMC4xMTc1IDAuMDUtMC4xNzI1IDAuMDc0IiBmaWxsPSIjZjVmNWY1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTI2NyIgZD0ibTMwNS41OSA5NDYuNTNjLTM4LjI2IDAtNjkuMjc2LTguMzIxMy02OS4yNzYtMTguNTg2IDAtNC43MzcyIDYuNjA1LTkuMDYxIDE3LjQ4Mi0xMi4zNDQgMC4wNDI1IDAuMDMxIDAuMDgzNyAwLjA2MSAwLjEyNjI1IDAuMDkxLTEwLjc1OCAzLjI2MzctMTcuMjg4IDcuNTUzNy0xNy4yODggMTIuMjUyIDAgMTAuMjE3IDMwLjg3MSAxOC41IDY4Ljk1NSAxOC41IDM4LjA4MiAwIDY4Ljk1NS04LjI4MjggNjguOTU1LTE4LjUgMC00LjY5ODgtNi41MzEyLTguOTg4OC0xNy4yODktMTIuMjUyIDAuMDQyNS0wLjAzIDAuMDgzOC0wLjA2IDAuMTI2MjUtMC4wOTEgMTAuODc4IDMuMjgyNyAxNy40ODQgNy42MDY1IDE3LjQ4NCAxMi4zNDQgMCAxMC4yNjUtMzEuMDE2IDE4LjU4Ni02OS4yNzYgMTguNTg2bTI4LjUxNi0zNS40MzVjMC4wNTc1LTAuMDI1IDAuMTE1LTAuMDQ5IDAuMTcyNS0wLjA3NC0wLjA1NzUgMC4wMjUtMC4xMTUgMC4wNDktMC4xNzI1IDAuMDc0IiBmaWxsPSIjZjRmNGY0Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTI2OSIgZD0ibTMwNS41OSA5NDYuNDVjLTM4LjA4NCAwLTY4Ljk1NS04LjI4MjctNjguOTU1LTE4LjUgMC00LjY5ODcgNi41My04Ljk4ODcgMTcuMjg4LTEyLjI1MiAwLjA0MTIgMC4wMyAwLjA4NjMgMC4wNjIgMC4xMjc1IDAuMDkxLTEwLjY0MiAzLjI0MzYtMTcuMDk1IDcuNS0xNy4wOTUgMTIuMTYxIDAgMTAuMTcgMzAuNzI5IDE4LjQxNSA2OC42MzUgMTguNDE1IDM3LjkwNSAwIDY4LjYzNC04LjI0NTIgNjguNjM0LTE4LjQxNSAwLTQuNjYxMS02LjQ1MjUtOC45MTc1LTE3LjA5NC0xMi4xNjEgMC4wNC0wLjAyOSAwLjA4NjMtMC4wNjIgMC4xMjYyNS0wLjA5MSAxMC43NTggMy4yNjM4IDE3LjI4OSA3LjU1MzggMTcuMjg5IDEyLjI1MiAwIDEwLjIxNy0zMC44NzIgMTguNS02OC45NTUgMTguNW0yOC4zNDQtMzUuMjc2YzAuMDU4OC0wLjAyNSAwLjExMjUtMC4wNDcgMC4xNzI1LTAuMDcyLTAuMDYgMC4wMjUtMC4xMTM3NSAwLjA0Ny0wLjE3MjUgMC4wNzIiIGZpbGw9IiNmNGY0ZjQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjcxIiBkPSJtMzA1LjU5IDk0Ni4zNmMtMzcuOTA2IDAtNjguNjM1LTguMjQ1Mi02OC42MzUtMTguNDE1IDAtNC42NjExIDYuNDUyNS04LjkxNzUgMTcuMDk1LTEyLjE2MSAwLjA0MjUgMC4wMzEgMC4wODUgMC4wNjEgMC4xMjc1IDAuMDkyLTEwLjUyNCAzLjIyMzYtMTYuOTAxIDcuNDQ2NC0xNi45MDEgMTIuMDY5IDAgMTAuMTIyIDMwLjU4NSAxOC4zMjkgNjguMzE0IDE4LjMyOXM2OC4zMTQtOC4yMDYxIDY4LjMxNC0xOC4zMjljMC00LjYyMjUtNi4zNzc1LTguODQ1My0xNi45MDItMTIuMDY5IDAuMDQzNy0wLjAzMSAwLjA4NS0wLjA2MSAwLjEyODc1LTAuMDkyIDEwLjY0MSAzLjI0MzYgMTcuMDk0IDcuNSAxNy4wOTQgMTIuMTYxIDAgMTAuMTctMzAuNzI5IDE4LjQxNS02OC42MzQgMTguNDE1bTI4LjE2OS0zNS4xMThjMC4wNTc1LTAuMDI0IDAuMTE2MjUtMC4wNDkgMC4xNzUtMC4wNzQtMC4wNTg3IDAuMDI1LTAuMTE3NSAwLjA1LTAuMTc1IDAuMDc0IiBmaWxsPSIjZjNmM2YzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTI3MyIgZD0ibTMwNS41OSA5NDYuMjhjLTM3LjcyOSAwLTY4LjMxNC04LjIwNjEtNjguMzE0LTE4LjMyOSAwLTQuNjIyNSA2LjM3NzUtOC44NDUyIDE2LjkwMS0xMi4wNjkgMC4wNDI1IDAuMDMgMC4wODUgMC4wNiAwLjEyNzUgMC4wOTEtMTAuNDA2IDMuMjA1LTE2LjcwOSA3LjM5MzUtMTYuNzA5IDExLjk3OCAwIDEwLjA3NSAzMC40NDEgMTguMjQzIDY3Ljk5NCAxOC4yNDMgMzcuNTUxIDAgNjcuOTkyLTguMTY3NCA2Ny45OTItMTguMjQzIDAtNC41ODQtNi4zMDEyLTguNzcyNS0xNi43MDktMTEuOTc4IDAuMDQyNS0wLjAzMSAwLjA4NS0wLjA2MSAwLjEyNzUtMC4wOTEgMTAuNTI1IDMuMjIzNyAxNi45MDIgNy40NDY0IDE2LjkwMiAxMi4wNjkgMCAxMC4xMjItMzAuNTg1IDE4LjMyOS02OC4zMTQgMTguMzI5bTI3Ljk5Mi0zNC45NThjMC4wNTg4LTAuMDI1IDAuMTE4NzUtMC4wNSAwLjE3NjI1LTAuMDc0LTAuMDU3NSAwLjAyNC0wLjExNzUgMC4wNDktMC4xNzYyNSAwLjA3NCIgZmlsbD0iI2YzZjNmMiIvPgogICA8cGF0aCBpZD0icGF0aDUyNzUiIGQ9Im0zMDUuNTkgOTQ2LjE5Yy0zNy41NTMgMC02Ny45OTQtOC4xNjc0LTY3Ljk5NC0xOC4yNDMgMC00LjU4NCA2LjMwMjUtOC43NzI1IDE2LjcwOS0xMS45NzggMC4wNDM3IDAuMDMgMC4wODYzIDAuMDYyIDAuMTI4NzUgMC4wOTEtMTAuMjg5IDMuMTg1MS0xNi41MTYgNy4zNDEzLTE2LjUxNiAxMS44ODYgMCAxMC4wMjcgMzAuMjk4IDE4LjE1NiA2Ny42NzMgMTguMTU2IDM3LjM3NCAwIDY3LjY3Mi04LjEyODkgNjcuNjcyLTE4LjE1NiAwLTQuNTQ0OS02LjIyODgtOC43MDExLTE2LjUxOC0xMS44ODYgMC4wNDM3LTAuMDMgMC4wODYyLTAuMDYyIDAuMTI4NzUtMC4wOTEgMTAuNDA4IDMuMjA1IDE2LjcwOSA3LjM5MzUgMTYuNzA5IDExLjk3OCAwIDEwLjA3NS0zMC40NDEgMTguMjQzLTY3Ljk5MiAxOC4yNDNtMjcuODE2LTM0Ljc5OWMwLjA2LTAuMDI1IDAuMTE2MjUtMC4wNDcgMC4xNzYyNS0wLjA3My0wLjA2IDAuMDI1LTAuMTE2MjUgMC4wNDgtMC4xNzYyNSAwLjA3MyIgZmlsbD0iI2YyZjJmMiIvPgogICA8cGF0aCBpZD0icGF0aDUyNzciIGQ9Im0zMDUuNTkgOTQ2LjFjLTM3LjM3NSAwLTY3LjY3My04LjEyODktNjcuNjczLTE4LjE1NiAwLTQuNTQ0OSA2LjIyNzUtOC43MDExIDE2LjUxNi0xMS44ODYgMC4wNDM3IDAuMDMgMC4wODYzIDAuMDYxIDAuMTI4NzUgMC4wOTEtMTAuMTcyIDMuMTY1LTE2LjMyNCA3LjI4NzUtMTYuMzI0IDExLjc5NSAwIDkuOTggMzAuMTU0IDE4LjA3IDY3LjM1MSAxOC4wNyAzNy4xOTggMCA2Ny4zNTEtOC4wODk5IDY3LjM1MS0xOC4wNyAwLTQuNTA3My02LjE1MTItOC42Mjk4LTE2LjMyNS0xMS43OTUgMC4wNDI1LTAuMDMgMC4wODYyLTAuMDYyIDAuMTI4NzUtMC4wOTEgMTAuMjg5IDMuMTg1MSAxNi41MTggNy4zNDEzIDE2LjUxOCAxMS44ODYgMCAxMC4wMjctMzAuMjk5IDE4LjE1Ni02Ny42NzIgMTguMTU2bTI3LjYzOS0zNC42NGMwLjA1ODgtMC4wMjQgMC4xMi0wLjA0OSAwLjE3NzUtMC4wNzItMC4wNTc1IDAuMDIzLTAuMTE4NzUgMC4wNDktMC4xNzc1IDAuMDcyIiBmaWxsPSIjZjJmMWYxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTI3OSIgZD0ibTMwNS41OSA5NDYuMDJjLTM3LjE5OCAwLTY3LjM1MS04LjA4OTgtNjcuMzUxLTE4LjA3IDAtNC41MDc0IDYuMTUxMi04LjYyOTkgMTYuMzI0LTExLjc5NSAwLjA0MzggMC4wMzEgMC4wODc1IDAuMDYxIDAuMTMgMC4wOTItMTAuMDU2IDMuMTQ1LTE2LjEzNCA3LjIzMzktMTYuMTM0IDExLjcwMyAwIDkuOTMyNiAzMC4wMTEgMTcuOTg0IDY3LjAzMSAxNy45ODRzNjcuMDMtOC4wNTEzIDY3LjAzLTE3Ljk4NGMwLTQuNDY4OC02LjA3NzUtOC41NTc3LTE2LjEzNC0xMS43MDMgMC4wNDM4LTAuMDMxIDAuMDg3NS0wLjA2MSAwLjEzLTAuMDkyIDEwLjE3NCAzLjE2NSAxNi4zMjUgNy4yODc1IDE2LjMyNSAxMS43OTUgMCA5Ljk4LTMwLjE1NCAxOC4wNy02Ny4zNTEgMTguMDdtMjcuNDYtMzQuNDhjMC4wNi0wLjAyNSAwLjExODc1LTAuMDQ5IDAuMTc4NzUtMC4wNzQtMC4wNiAwLjAyNS0wLjExODc1IDAuMDQ5LTAuMTc4NzUgMC4wNzQiIGZpbGw9IiNmMWYxZjEiLz4KICAgPHBhdGggaWQ9InBhdGg1MjgxIiBkPSJtMzA1LjU5IDk0NS45M2MtMzcuMDIgMC02Ny4wMzEtOC4wNTEzLTY3LjAzMS0xNy45ODQgMC00LjQ2ODcgNi4wNzc1LTguNTU3NiAxNi4xMzQtMTEuNzAzIDAuMDQzOCAwLjAzIDAuMDg3NSAwLjA2MiAwLjEzIDAuMDkxLTkuOTQgMy4xMjUtMTUuOTQyIDcuMTgwMi0xNS45NDIgMTEuNjExIDAgOS44ODQ3IDI5Ljg2NiAxNy44OTcgNjYuNzEgMTcuODk3IDM2Ljg0MiAwIDY2LjcxLTguMDEyNyA2Ni43MS0xNy44OTcgMC00LjQzMTItNi4wMDM4LTguNDg2NC0xNS45NDQtMTEuNjExIDAuMDQzOC0wLjAzIDAuMDg3NS0wLjA2MSAwLjEzLTAuMDkxIDEwLjA1NiAzLjE0NSAxNi4xMzQgNy4yMzM5IDE2LjEzNCAxMS43MDMgMCA5LjkzMjYtMzAuMDEgMTcuOTg0LTY3LjAzIDE3Ljk4NG0yNy4yODEtMzQuMzIxYzAuMDYtMC4wMjUgMC4xMTg3NS0wLjA0OSAwLjE3ODc1LTAuMDczLTAuMDYgMC4wMjQtMC4xMTg3NSAwLjA0OC0wLjE3ODc1IDAuMDczIiBmaWxsPSIjZjBmMGYwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTI4MyIgZD0ibTMwNS41OSA5NDUuODRjLTM2Ljg0NCAwLTY2LjcxLTguMDEyNy02Ni43MS0xNy44OTggMC00LjQzMTEgNi4wMDI1LTguNDg2NCAxNS45NDItMTEuNjExIDAuMDQzNyAwLjAzIDAuMDg3NSAwLjA2MiAwLjEzMTI1IDAuMDkxLTkuODI1IDMuMTA2NC0xNS43NTQgNy4xMjc0LTE1Ljc1NCAxMS41MiAwIDkuODM3NCAyOS43MjQgMTcuODEyIDY2LjM5IDE3LjgxMiAzNi42NjUgMCA2Ni4zODktNy45NzUxIDY2LjM4OS0xNy44MTIgMC00LjM5MjYtNS45Mjc1LTguNDEzNi0xNS43NTItMTEuNTIgMC4wNDI1LTAuMDMgMC4wODc1LTAuMDYxIDAuMTMtMC4wOTEgOS45NCAzLjEyNSAxNS45NDQgNy4xODAzIDE1Ljk0NCAxMS42MTEgMCA5Ljg4NDgtMjkuODY4IDE3Ljg5OC02Ni43MSAxNy44OThtMjcuMS0zNC4xNjNjMC4wNi0wLjAyNCAwLjEyLTAuMDQ5IDAuMTgxMjUtMC4wNzItMC4wNjEyIDAuMDIzLTAuMTIxMjUgMC4wNDktMC4xODEyNSAwLjA3MiIgZmlsbD0iI2YwZjBlZiIvPgogICA8cGF0aCBpZD0icGF0aDUyODUiIGQ9Im0zMDUuNTkgOTQ1Ljc2Yy0zNi42NjYgMC02Ni4zOS03Ljk3NTEtNjYuMzktMTcuODEyIDAtNC4zOTI2IDUuOTI4OC04LjQxMzYgMTUuNzU0LTExLjUyIDAuMDQ1IDAuMDMxIDAuMDg1IDAuMDYgMC4xMzEyNSAwLjA5MS05LjcxMTIgMy4wODY1LTE1LjU2NCA3LjA3MzgtMTUuNTY0IDExLjQyOSAwIDkuNzkgMjkuNTggMTcuNzI2IDY2LjA2OSAxNy43MjZzNjYuMDY4LTcuOTM2IDY2LjA2OC0xNy43MjZjMC00LjM1NS01Ljg1MjUtOC4zNDIzLTE1LjU2Mi0xMS40MjkgMC4wNDUtMC4wMzEgMC4wODUtMC4wNiAwLjEzMTI1LTAuMDkxIDkuODI1IDMuMTA2NCAxNS43NTIgNy4xMjc0IDE1Ljc1MiAxMS41MiAwIDkuODM3NC0yOS43MjQgMTcuODEyLTY2LjM4OSAxNy44MTJtMjYuOTE4LTM0LjAwNWwwLjE4MjUtMC4wNzNjLTAuMDYgMC4wMjQtMC4xMjEyNSAwLjA0OS0wLjE4MjUgMC4wNzMiIGZpbGw9IiNlZmVmZWYiLz4KICAgPHBhdGggaWQ9InBhdGg1Mjg3IiBkPSJtMzA1LjU5IDk0NS42N2MtMzYuNDg5IDAtNjYuMDY5LTcuOTM2MS02Ni4wNjktMTcuNzI2IDAtNC4zNTUgNS44NTI1LTguMzQyMiAxNS41NjQtMTEuNDI5IDAuMDQyNSAwLjAzIDAuMDg3NSAwLjA2MiAwLjEzMTI1IDAuMDkyLTkuNTkzOCAzLjA2NDgtMTUuMzc0IDcuMDItMTUuMzc0IDExLjMzNiAwIDkuNzQyNyAyOS40MzYgMTcuNjQgNjUuNzQ4IDE3LjY0IDM2LjMxMSAwIDY1Ljc0OC03Ljg5NzUgNjUuNzQ4LTE3LjY0IDAtNC4zMTYzLTUuNzgtOC4yNzE1LTE1LjM3NS0xMS4zMzYgMC4wNDM3LTAuMDMgMC4wODg3LTAuMDYyIDAuMTMyNS0wLjA5MiA5LjcxIDMuMDg2NSAxNS41NjIgNy4wNzM3IDE1LjU2MiAxMS40MjkgMCA5Ljc5LTI5LjU3OSAxNy43MjYtNjYuMDY4IDE3LjcyNm0yNi43MzUtMzMuODQ2YzAuMDYtMC4wMjMgMC4xMjI1LTAuMDQ5IDAuMTgyNS0wLjA3Mi0wLjA2IDAuMDIzLTAuMTIyNSAwLjA0OS0wLjE4MjUgMC4wNzIiIGZpbGw9IiNlZWUiLz4KICAgPHBhdGggaWQ9InBhdGg1Mjg5IiBkPSJtMzA1LjU5IDk0NS41OWMtMzYuMzExIDAtNjUuNzQ4LTcuODk3Ni02NS43NDgtMTcuNjQgMC00LjMxNjQgNS43OC04LjI3MTUgMTUuMzc0LTExLjMzNiAwLjA0MzggMC4wMyAwLjA4ODggMC4wNjIgMC4xMzI1IDAuMDkxLTkuNDgxMiAzLjA0NjUtMTUuMTg2IDYuOTY2NS0xNS4xODYgMTEuMjQ1IDAgOS42OTQ5IDI5LjI5MiAxNy41NTQgNjUuNDI4IDE3LjU1NCAzNi4xMzQgMCA2NS40MjYtNy44NTg4IDY1LjQyNi0xNy41NTQgMC00LjI3ODctNS43MDM4LTguMTk4Ny0xNS4xODUtMTEuMjQ1IDAuMDQyNS0wLjAzIDAuMDg4OC0wLjA2MSAwLjEzMTI1LTAuMDkxIDkuNTk1IDMuMDY0OSAxNS4zNzUgNy4wMiAxNS4zNzUgMTEuMzM2IDAgOS43NDI2LTI5LjQzNiAxNy42NC02NS43NDggMTcuNjRtMjYuNTUxLTMzLjY4OGMwLjA2MjUtMC4wMjUgMC4xMjEyNS0wLjA0OCAwLjE4Mzc1LTAuMDczLTAuMDYyNSAwLjAyNS0wLjEyMTI1IDAuMDQ4LTAuMTgzNzUgMC4wNzMiIGZpbGw9IiNlZWVlZWQiLz4KICAgPHBhdGggaWQ9InBhdGg1MjkxIiBkPSJtMzA1LjU5IDk0NS41Yy0zNi4xMzUgMC02NS40MjgtNy44NTg4LTY1LjQyOC0xNy41NTQgMC00LjI3ODcgNS43MDUtOC4xOTg3IDE1LjE4Ni0xMS4yNDUgMC4wNDUgMC4wMzEgMC4wODYzIDAuMDYgMC4xMzI1IDAuMDkxLTkuMzY3NSAzLjAyNjItMTQuOTk4IDYuOTEzNS0xNC45OTggMTEuMTU0IDAgOS42NDc1IDI5LjE0OSAxNy40NjcgNjUuMTA2IDE3LjQ2NyAzNS45NTYgMCA2NS4xMDYtNy44MTk4IDY1LjEwNi0xNy40NjcgMC00LjI0MDItNS42MzEyLTguMTI3NS0xNC45OTktMTEuMTU0IDAuMDQ2My0wLjAzMSAwLjA4NzUtMC4wNiAwLjEzMzc1LTAuMDkxIDkuNDgxMiAzLjA0NjQgMTUuMTg1IDYuOTY2NCAxNS4xODUgMTEuMjQ1IDAgOS42OTQ5LTI5LjI5MiAxNy41NTQtNjUuNDI2IDE3LjU1NG0yNi4zNjUtMzMuNTNjMC4wNjEzLTAuMDI0IDAuMTI1LTAuMDQ5IDAuMTg2MjUtMC4wNzEtMC4wNjEzIDAuMDIzLTAuMTI1IDAuMDQ3LTAuMTg2MjUgMC4wNzEiIGZpbGw9IiNlZGVkZWMiLz4KICAgPHBhdGggaWQ9InBhdGg1MjkzIiBkPSJtMzA1LjU5IDk0NS40MWMtMzUuOTU4IDAtNjUuMTA2LTcuODE5OC02NS4xMDYtMTcuNDY3IDAtNC4yNDAyIDUuNjMtOC4xMjc1IDE0Ljk5OC0xMS4xNTQgMC4wNDM3IDAuMDMgMC4wOSAwLjA2MiAwLjEzMzc1IDAuMDkyLTkuMjUxMiAzLjAwNTMtMTQuODExIDYuODU4OC0xNC44MTEgMTEuMDYyIDAgOS42MDAxIDI5LjAwNiAxNy4zODEgNjQuNzg2IDE3LjM4MXM2NC43ODUtNy43ODEyIDY0Ljc4NS0xNy4zODFjMC00LjIwMjctNS41NTg4LTguMDU2Mi0xNC44MS0xMS4wNjIgMC4wNDI1LTAuMDMgMC4wOS0wLjA2MiAwLjEzMjUtMC4wOTIgOS4zNjc1IDMuMDI2MiAxNC45OTkgNi45MTM1IDE0Ljk5OSAxMS4xNTQgMCA5LjY0NzUtMjkuMTUgMTcuNDY3LTY1LjEwNiAxNy40NjdtMjYuMTc5LTMzLjM3MWMwLjA2MjUtMC4wMjQgMC4xMjM3NS0wLjA0NyAwLjE4NjI1LTAuMDcyLTAuMDYyNSAwLjAyNS0wLjEyMzc1IDAuMDQ4LTAuMTg2MjUgMC4wNzIiIGZpbGw9IiNlZGVkZWMiLz4KICAgPHBhdGggaWQ9InBhdGg1Mjk1IiBkPSJtMzA1LjU5IDk0NS4zM2MtMzUuNzggMC02NC43ODYtNy43ODEzLTY0Ljc4Ni0xNy4zODEgMC00LjIwMjYgNS41Ni04LjA1NjEgMTQuODExLTExLjA2MiAwLjA0NSAwLjAzMiAwLjA4NzUgMC4wNiAwLjEzMzc1IDAuMDkxLTkuMTM4OCAyLjk4NTQtMTQuNjI0IDYuODA2Ni0xNC42MjQgMTAuOTcgMCA5LjU1MjcgMjguODYyIDE3LjI5NSA2NC40NjUgMTcuMjk1IDM1LjYwMiAwIDY0LjQ2NS03Ljc0MjIgNjQuNDY1LTE3LjI5NSAwLTQuMTYzNy01LjQ4NS03Ljk4NDktMTQuNjI0LTEwLjk3IDAuMDQ1LTAuMDMxIDAuMDg3NS0wLjA1OSAwLjEzMzc1LTAuMDkxIDkuMjUxMiAzLjAwNTQgMTQuODEgNi44NTg5IDE0LjgxIDExLjA2MiAwIDkuNjAwMS0yOS4wMDUgMTcuMzgxLTY0Ljc4NSAxNy4zODFtMjUuOTkxLTMzLjIxNGMwLjA2MjUtMC4wMjQgMC4xMjM3NS0wLjA0NyAwLjE4NzUtMC4wNzEtMC4wNjI1IDAuMDI0LTAuMTI1IDAuMDQ3LTAuMTg3NSAwLjA3MSIgZmlsbD0iI2VjZWNlYiIvPgogICA8cGF0aCBpZD0icGF0aDUyOTciIGQ9Im0zMDUuNTkgOTQ1LjI0Yy0zNS42MDMgMC02NC40NjUtNy43NDIxLTY0LjQ2NS0xNy4yOTUgMC00LjE2MzYgNS40ODUtNy45ODQ5IDE0LjYyNC0xMC45NyAwLjA0MzcgMC4wMyAwLjA5MTMgMC4wNjIgMC4xMzM3NSAwLjA5My05LjAyMzggMi45NjM4LTE0LjQzNiA2Ljc1MS0xNC40MzYgMTAuODc4IDAgOS41MDQ4IDI4LjcxOCAxNy4yMSA2NC4xNDQgMTcuMjEgMzUuNDI1IDAgNjQuMTQ0LTcuNzA1MSA2NC4xNDQtMTcuMjEgMC00LjEyNjUtNS40MTI1LTcuOTEzNy0xNC40MzgtMTAuODc4IDAuMDQzOC0wLjAzIDAuMDkxMy0wLjA2MiAwLjEzNS0wLjA5MyA5LjEzODggMi45ODUzIDE0LjYyNCA2LjgwNjYgMTQuNjI0IDEwLjk3IDAgOS41NTI4LTI4Ljg2MiAxNy4yOTUtNjQuNDY1IDE3LjI5NW0yNS44MDItMzMuMDU1YzAuMDYyNS0wLjAyNCAwLjEyNS0wLjA0OSAwLjE4ODc1LTAuMDczLTAuMDYzNyAwLjAyNC0wLjEyNjI1IDAuMDQ5LTAuMTg4NzUgMC4wNzMiIGZpbGw9IiNlY2ViZWEiLz4KICAgPHBhdGggaWQ9InBhdGg1Mjk5IiBkPSJtMzA1LjU5IDk0NS4xNmMtMzUuNDI2IDAtNjQuMTQ0LTcuNzA1MS02NC4xNDQtMTcuMjEgMC00LjEyNjUgNS40MTI1LTcuOTEzNiAxNC40MzYtMTAuODc4IDAuMDQ2MyAwLjAzMSAwLjA5IDAuMDYgMC4xMzYyNSAwLjA5MS04LjkxMjUgMi45NDM5LTE0LjI1MiA2LjY5NzMtMTQuMjUyIDEwLjc4NiAwIDkuNDU3NSAyOC41NzUgMTcuMTI0IDYzLjgyNCAxNy4xMjRzNjMuODIyLTcuNjY2MSA2My44MjItMTcuMTI0YzAtNC4wODg4LTUuMzQtNy44NDIyLTE0LjI1MS0xMC43ODYgMC4wNDYyLTAuMDMxIDAuMDg4OC0wLjA2IDAuMTM1LTAuMDkxIDkuMDI1IDIuOTYzOSAxNC40MzggNi43NTEgMTQuNDM4IDEwLjg3OCAwIDkuNTA0OS0yOC43MTkgMTcuMjEtNjQuMTQ0IDE3LjIxbTI1LjYxMS0zMi44OTljMC4wNjM3LTAuMDIzIDAuMTI3NS0wLjA0NyAwLjE5MTI1LTAuMDcxLTAuMDYzNyAwLjAyMy0wLjEyNzUgMC4wNDctMC4xOTEyNSAwLjA3MSIgZmlsbD0iI2ViZWJlYSIvPgogICA8cGF0aCBpZD0icGF0aDUzMDEiIGQ9Im0zMDUuNTkgOTQ1LjA3Yy0zNS4yNDkgMC02My44MjQtNy42NjYxLTYzLjgyNC0xNy4xMjQgMC00LjA4ODkgNS4zNC03Ljg0MjIgMTQuMjUyLTEwLjc4NiAwLjA0MjUgMC4wMyAwLjA5MTMgMC4wNjIgMC4xMzUgMC4wOTEtOC43OTg4IDIuOTI0OS0xNC4wNjYgNi42NDUyLTE0LjA2NiAxMC42OTUgMCA5LjQxMDEgMjguNDMxIDE3LjAzOCA2My41MDMgMTcuMDM4IDM1LjA3MSAwIDYzLjUwMi03LjYyNzUgNjMuNTAyLTE3LjAzOCAwLTQuMDQ5Ny01LjI2NzUtNy43Ny0xNC4wNjgtMTAuNjk1IDAuMDQzNy0wLjAyOSAwLjA5MjUtMC4wNjIgMC4xMzYyNS0wLjA5MSA4LjkxMTIgMi45NDM5IDE0LjI1MSA2LjY5NzMgMTQuMjUxIDEwLjc4NiAwIDkuNDU3NS0yOC41NzQgMTcuMTI0LTYzLjgyMiAxNy4xMjRtMjUuNDItMzIuNzQxYzAuMDYyNS0wLjAyNCAwLjEyODc1LTAuMDQ3IDAuMTkxMjUtMC4wNzEtMC4wNjI1IDAuMDI0LTAuMTI4NzUgMC4wNDgtMC4xOTEyNSAwLjA3MSIgZmlsbD0iI2VhZWFlOSIvPgogICA8cGF0aCBpZD0icGF0aDUzMDMiIGQ9Im0zMDUuNTkgOTQ0Ljk4Yy0zNS4wNzEgMC02My41MDMtNy42Mjc1LTYzLjUwMy0xNy4wMzggMC00LjA0OTcgNS4yNjc1LTcuNzcgMTQuMDY2LTEwLjY5NSAwLjA0NjMgMC4wMzEgMC4wOSAwLjA2MSAwLjEzNjI1IDAuMDkyLTguNjg1IDIuOTAzOC0xMy44ODEgNi41ODk4LTEzLjg4MSAxMC42MDIgMCA5LjM2MjMgMjguMjg2IDE2Ljk1MSA2My4xODEgMTYuOTUxIDM0Ljg5NCAwIDYzLjE4MS03LjU4ODkgNjMuMTgxLTE2Ljk1MSAwLTQuMDEyNy01LjE5NjItNy42OTg3LTEzLjg4Mi0xMC42MDIgMC4wNDYzLTAuMDMxIDAuMDkxMy0wLjA2MSAwLjEzNjI1LTAuMDkyIDguOCAyLjkyNDkgMTQuMDY4IDYuNjQ1MiAxNC4wNjggMTAuNjk1IDAgOS40MTAxLTI4LjQzMSAxNy4wMzgtNjMuNTAyIDE3LjAzOG0yNS4yMjgtMzIuNTg0YzAuMDY1LTAuMDI0IDAuMTI3NS0wLjA0NiAwLjE5MjUtMC4wNzEtMC4wNjUgMC4wMjUtMC4xMjc1IDAuMDQ4LTAuMTkyNSAwLjA3MSIgZmlsbD0iI2VhZWFlOCIvPgogICA8cGF0aCBpZD0icGF0aDUzMDUiIGQ9Im0zMDUuNTkgOTQ0LjljLTM0Ljg5NSAwLTYzLjE4MS03LjU4ODktNjMuMTgxLTE2Ljk1MSAwLTQuMDEyNyA1LjE5NjItNy42OTg3IDEzLjg4MS0xMC42MDIgMC4wNDYyIDAuMDMxIDAuMDkxMyAwLjA2MSAwLjEzNjI1IDAuMDkxLTguNTcyNSAyLjg4MzgtMTMuNjk4IDYuNTM3Ni0xMy42OTggMTAuNTExIDAgOS4zMTUgMjguMTQ0IDE2Ljg2NSA2Mi44NjEgMTYuODY1IDM0LjcxNiAwIDYyLjg2LTcuNTQ5NyA2Mi44Ni0xNi44NjUgMC0zLjk3MzctNS4xMjUtNy42Mjc1LTEzLjY5OC0xMC41MTEgMC4wNDYyLTAuMDMgMC4wOTEzLTAuMDYgMC4xMzYyNS0wLjA5MSA4LjY4NjIgMi45MDM4IDEzLjg4MiA2LjU4OTggMTMuODgyIDEwLjYwMiAwIDkuMzYyMy0yOC4yODggMTYuOTUxLTYzLjE4MSAxNi45NTFtMjUuMDMyLTMyLjQyNmMwLjA2MzctMC4wMjIgMC4xMzEyNS0wLjA0NyAwLjE5NS0wLjA3MS0wLjA2MzcgMC4wMjQtMC4xMzEyNSAwLjA0OS0wLjE5NSAwLjA3MSIgZmlsbD0iI2U5ZTllOCIvPgogICA8cGF0aCBpZD0icGF0aDUzMDciIGQ9Im0zMDUuNTkgOTQ0LjgxYy0zNC43MTggMC02Mi44NjEtNy41NDk4LTYyLjg2MS0xNi44NjUgMC0zLjk3MzYgNS4xMjUtNy42Mjc1IDEzLjY5OC0xMC41MTEgMC4wNDYyIDAuMDMxIDAuMDkxMyAwLjA2MSAwLjEzNzUgMC4wOTItOC40NiAyLjg2MjgtMTMuNTE0IDYuNDgzLTEzLjUxNCAxMC40MTkgMCA5LjI2NzYgMjggMTYuNzc5IDYyLjU0IDE2Ljc3OXM2Mi41NC03LjUxMTIgNjIuNTQtMTYuNzc5YzAtMy45MzYtNS4wNTM4LTcuNTU2Mi0xMy41MTUtMTAuNDE5IDAuMDQ2Mi0wLjAzMSAwLjA5MTItMC4wNjEgMC4xMzc1LTAuMDkyIDguNTcyNSAyLjg4MzcgMTMuNjk4IDYuNTM3NiAxMy42OTggMTAuNTExIDAgOS4zMTUtMjguMTQ0IDE2Ljg2NS02Mi44NiAxNi44NjVtMjQuODM4LTMyLjI2OGMwLjA2NS0wLjAyNCAwLjEzLTAuMDQ3IDAuMTk1LTAuMDcxLTAuMDY1IDAuMDI0LTAuMTMgMC4wNDctMC4xOTUgMC4wNzEiIGZpbGw9IiNlOWU4ZTciLz4KICAgPHBhdGggaWQ9InBhdGg1MzA5IiBkPSJtMzA1LjU5IDk0NC43M2MtMzQuNTQgMC02Mi41NC03LjUxMTItNjIuNTQtMTYuNzc5IDAtMy45MzYgNS4wNTM4LTcuNTU2MSAxMy41MTQtMTAuNDE5IDAuMDQ2MiAwLjAzMSAwLjA5MjUgMC4wNjIgMC4xMzg3NSAwLjA5My04LjM1IDIuODQxMy0xMy4zMzIgNi40MjczLTEzLjMzMiAxMC4zMjYgMCA5LjIyMDMgMjcuODU4IDE2LjY5NCA2Mi4yMiAxNi42OTRzNjIuMjE5LTcuNDczNiA2Mi4yMTktMTYuNjk0YzAtMy44OTg4LTQuOTgyNS03LjQ4NDgtMTMuMzMxLTEwLjMyNiAwLjA0NjMtMC4wMzEgMC4wOTEzLTAuMDYyIDAuMTM3NS0wLjA5MyA4LjQ2MTIgMi44NjI5IDEzLjUxNSA2LjQ4MyAxMy41MTUgMTAuNDE5IDAgOS4yNjc2LTI4IDE2Ljc3OS02Mi41NCAxNi43NzltMjQuNjQtMzIuMTExYzAuMDY2My0wLjAyNCAwLjEzMTI1LTAuMDQ3IDAuMTk3NS0wLjA3MS0wLjA2NjIgMC4wMjQtMC4xMzEyNSAwLjA0Ny0wLjE5NzUgMC4wNzEiIGZpbGw9IiNlOGU4ZTYiLz4KICAgPHBhdGggaWQ9InBhdGg1MzExIiBkPSJtMzA1LjU5IDk0NC42NGMtMzQuMzYzIDAtNjIuMjItNy40NzM2LTYyLjIyLTE2LjY5NCAwLTMuODk4OSA0Ljk4MjUtNy40ODQ5IDEzLjMzMi0xMC4zMjYgMC4wNDYyIDAuMDMgMC4wOTEzIDAuMDYgMC4xMzg3NSAwLjA5MS04LjIzNzUgMi44MTk5LTEzLjE1IDYuMzczNS0xMy4xNSAxMC4yMzUgMCA5LjE3MjMgMjcuNzEyIDE2LjYwNyA2MS44OTkgMTYuNjA3IDM0LjE4NSAwIDYxLjg5OS03LjQzNTEgNjEuODk5LTE2LjYwNyAwLTMuODYxNC00LjkxMjUtNy40MTUtMTMuMTUtMTAuMjM1IDAuMDQ2Mi0wLjAzIDAuMDkyNS0wLjA2MiAwLjEzODc1LTAuMDkxIDguMzQ4OCAyLjg0MTIgMTMuMzMxIDYuNDI3MiAxMy4zMzEgMTAuMzI2IDAgOS4yMjAzLTI3Ljg1NiAxNi42OTQtNjIuMjE5IDE2LjY5NG0yNC40NDEtMzEuOTU3YzAuMDY2Mi0wLjAyMiAwLjEzMjUtMC4wNDYgMC4xOTg3NS0wLjA3LTAuMDY2MiAwLjAyNC0wLjEzMjUgMC4wNDctMC4xOTg3NSAwLjA3IiBmaWxsPSIjZTdlN2U2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTMxMyIgZD0ibTMwNS41OSA5NDQuNTVjLTM0LjE4NiAwLTYxLjg5OS03LjQzNS02MS44OTktMTYuNjA3IDAtMy44NjE0IDQuOTEyNS03LjQxNSAxMy4xNS0xMC4yMzUgMC4wNDYzIDAuMDMxIDAuMDkyNSAwLjA2MSAwLjEzODc1IDAuMDkyLTguMTI2MiAyLjc5ODktMTIuOTY4IDYuMzE5OS0xMi45NjggMTAuMTQzIDAgOS4xMjM1IDI3LjU2OSAxNi41MjIgNjEuNTc4IDE2LjUyMiAzNC4wMDggMCA2MS41NzgtNy4zOTggNjEuNTc4LTE2LjUyMiAwLTMuODIyOC00Ljg0MjUtNy4zNDM4LTEyLjk2OS0xMC4xNDMgMC4wNDc1LTAuMDMxIDAuMDkzNy0wLjA2MSAwLjE0LTAuMDkyIDguMjM3NSAyLjgxOTkgMTMuMTUgNi4zNzM1IDEzLjE1IDEwLjIzNSAwIDkuMTcyNC0yNy43MTQgMTYuNjA3LTYxLjg5OSAxNi42MDdtMjQuMjQxLTMxLjc5OWMwLjA2ODctMC4wMjQgMC4xMzI1LTAuMDQ2IDAuMi0wLjA3MS0wLjA2NzUgMC4wMjYtMC4xMzEyNSAwLjA0OC0wLjIgMC4wNzEiIGZpbGw9IiNlN2U2ZTUiLz4KICAgPHBhdGggaWQ9InBhdGg1MzE1IiBkPSJtMzA1LjU5IDk0NC40N2MtMzQuMDA5IDAtNjEuNTc4LTcuMzk4LTYxLjU3OC0xNi41MjIgMC0zLjgyMjcgNC44NDEyLTcuMzQzNyAxMi45NjgtMTAuMTQzIDAuMDQ2MiAwLjAzIDAuMDkzNyAwLjA2MiAwLjE0IDAuMDkxLTguMDE2MiAyLjc3ODctMTIuNzg4IDYuMjY2MS0xMi43ODggMTAuMDUxIDAgOS4wNzc3IDI3LjQyNiAxNi40MzUgNjEuMjU4IDE2LjQzNSAzMy44MzEgMCA2MS4yNTYtNy4zNTc0IDYxLjI1Ni0xNi40MzUgMC0zLjc4NTEtNC43NzEyLTcuMjcyNS0xMi43ODYtMTAuMDUxIDAuMDQ2Mi0wLjAzIDAuMDkyNS0wLjA2MSAwLjEzODc1LTAuMDkxIDguMTI2MiAyLjc5ODkgMTIuOTY5IDYuMzE5OSAxMi45NjkgMTAuMTQzIDAgOS4xMjM1LTI3LjU3IDE2LjUyMi02MS41NzggMTYuNTIybTI0LjA0LTMxLjY0M2MwLjA2NjItMC4wMjIgMC4xMzYyNS0wLjA0NiAwLjIwMTI1LTAuMDctMC4wNjUgMC4wMjQtMC4xMzUgMC4wNDgtMC4yMDEyNSAwLjA3IiBmaWxsPSIjZTZlNmU0Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTMxNyIgZD0ibTMwNS41OSA5NDQuMzhjLTMzLjgzMSAwLTYxLjI1OC03LjM1NzUtNjEuMjU4LTE2LjQzNSAwLTMuNzg1MSA0Ljc3MTItNy4yNzI1IDEyLjc4OC0xMC4wNTEgMC4wNDYzIDAuMDMxIDAuMDkzNyAwLjA2MiAwLjE0IDAuMDkyLTcuOTA2MiAyLjc1NzgtMTIuNjA2IDYuMjExNS0xMi42MDYgOS45NTkgMCA5LjAyODcgMjcuMjgxIDE2LjM0OSA2MC45MzYgMTYuMzQ5IDMzLjY1NCAwIDYwLjkzNi03LjMxOTkgNjAuOTM2LTE2LjM0OSAwLTMuNzQ3NS00LjcwMTItNy4yMDEyLTEyLjYwOC05Ljk1OSAwLjA0NzUtMC4wMyAwLjA5NS0wLjA2MSAwLjE0MTI1LTAuMDkyIDguMDE1IDIuNzc4NyAxMi43ODYgNi4yNjYxIDEyLjc4NiAxMC4wNTEgMCA5LjA3NzYtMjcuNDI1IDE2LjQzNS02MS4yNTYgMTYuNDM1bTIzLjgzOC0zMS40ODVjMC4wNjg3LTAuMDI0IDAuMTM1LTAuMDQ4IDAuMjAyNS0wLjA3MS0wLjA2NzUgMC4wMjQtMC4xMzM3NSAwLjA0Ny0wLjIwMjUgMC4wNzEiIGZpbGw9IiNlNWU1ZTMiLz4KICAgPHBhdGggaWQ9InBhdGg1MzE5IiBkPSJtMzA1LjU5IDk0NC4zYy0zMy42NTUgMC02MC45MzYtNy4zMTk5LTYwLjkzNi0xNi4zNDkgMC0zLjc0NzUgNC43LTcuMjAxMSAxMi42MDYtOS45NTkgMC4wNDYyIDAuMDMgMC4wOTUgMC4wNjIgMC4xNDEyNSAwLjA5My03Ljc5NSAyLjczNjQtMTIuNDI4IDYuMTU2My0xMi40MjggOS44NjYzIDAgOC45ODE1IDI3LjEzOSAxNi4yNjMgNjAuNjE2IDE2LjI2MyAzMy40NzYgMCA2MC42MTUtNy4yODEyIDYwLjYxNS0xNi4yNjMgMC0zLjcxLTQuNjMxMi03LjEyOTktMTIuNDI4LTkuODY2MyAwLjA0NzUtMC4wMzEgMC4wOTUtMC4wNjIgMC4xNDEyNS0wLjA5MyA3LjkwNjIgMi43NTc5IDEyLjYwOCA2LjIxMTUgMTIuNjA4IDkuOTU5IDAgOS4wMjg4LTI3LjI4MiAxNi4zNDktNjAuOTM2IDE2LjM0OW0yMy42MzItMzEuMzI5YzAuMDY4Ny0wLjAyNCAwLjEzNzUtMC4wNDcgMC4yMDUtMC4wNy0wLjA2NzUgMC4wMjItMC4xMzYyNSAwLjA0Ni0wLjIwNSAwLjA3IiBmaWxsPSIjZTRlNGUzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTMyMSIgZD0ibTMwNS41OSA5NDQuMjFjLTMzLjQ3OCAwLTYwLjYxNi03LjI4MTItNjAuNjE2LTE2LjI2MyAwLTMuNzEgNC42MzI1LTcuMTI5OSAxMi40MjgtOS44NjYyIDAuMDQ2MyAwLjAzIDAuMDk1IDAuMDYxIDAuMTQxMjUgMC4wOTEtNy42ODUgMi43MTYyLTEyLjI0OCA2LjEwMjUtMTIuMjQ4IDkuNzc0OCAwIDguOTMzNyAyNi45OTUgMTYuMTc2IDYwLjI5NSAxNi4xNzZzNjAuMjk0LTcuMjQyNiA2MC4yOTQtMTYuMTc2YzAtMy42NzIzLTQuNTYxMi03LjA1ODYtMTIuMjQ4LTkuNzc0OCAwLjA0NjMtMC4wMyAwLjA5NS0wLjA2MiAwLjE0MTI1LTAuMDkxIDcuNzk2MiAyLjczNjMgMTIuNDI4IDYuMTU2MiAxMi40MjggOS44NjYyIDAgOC45ODE1LTI3LjEzOSAxNi4yNjMtNjAuNjE1IDE2LjI2M20yMy40MjgtMzEuMTczYzAuMDY3NS0wLjAyNCAwLjEzNzUtMC4wNDYgMC4yMDUtMC4wNy0wLjA2NzUgMC4wMjQtMC4xMzc1IDAuMDQ2LTAuMjA1IDAuMDciIGZpbGw9IiNlNGUzZTIiLz4KICAgPHBhdGggaWQ9InBhdGg1MzIzIiBkPSJtMzA1LjU5IDk0NC4xMmMtMzMuMyAwLTYwLjI5NS03LjI0MjctNjAuMjk1LTE2LjE3NiAwLTMuNjcyNCA0LjU2MjUtNy4wNTg2IDEyLjI0OC05Ljc3NDkgMC4wNSAwLjAzMiAwLjA5MzcgMC4wNjEgMC4xNDI1IDAuMDkyLTcuNTc3NSAyLjY5MzktMTIuMDY5IDYuMDQ3OS0xMi4wNjkgOS42ODI3IDAgOC44ODYyIDI2Ljg1MSAxNi4wOTEgNTkuOTc0IDE2LjA5MSAzMy4xMjIgMCA1OS45NzQtNy4yMDUxIDU5Ljk3NC0xNi4wOTEgMC0zLjYzNDgtNC40OTI1LTYuOTg4OC0xMi4wNy05LjY4MjcgMC4wNDg3LTAuMDMxIDAuMDkzNy0wLjA2IDAuMTQyNS0wLjA5MiA3LjY4NjIgMi43MTYzIDEyLjI0OCA2LjEwMjUgMTIuMjQ4IDkuNzc0OSAwIDguOTMzNi0yNi45OTQgMTYuMTc2LTYwLjI5NCAxNi4xNzZtMjMuMjItMzEuMDE2YzAuMDY3NS0wLjAyNCAwLjEzODc1LTAuMDQ4IDAuMjA3NS0wLjA3LTAuMDY4OCAwLjAyMi0wLjE0IDAuMDQ2LTAuMjA3NSAwLjA3IiBmaWxsPSIjZTNlM2UxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTMyNSIgZD0ibTMwNS41OSA5NDQuMDRjLTMzLjEyMyAwLTU5Ljk3NC03LjIwNS01OS45NzQtMTYuMDkxIDAtMy42MzQ3IDQuNDkxMi02Ljk4ODcgMTIuMDY5LTkuNjgyNiAwLjA0NzUgMC4wMyAwLjA5NjIgMC4wNjIgMC4xNDM3NSAwLjA5My03LjQ2NzUgMi42NzI0LTExLjg5MiA1Ljk5MjItMTEuODkyIDkuNTg5OSAwIDguODM4OCAyNi43MDggMTYuMDA1IDU5LjY1NCAxNi4wMDUgMzIuOTQ1IDAgNTkuNjUyLTcuMTY2IDU5LjY1Mi0xNi4wMDUgMC0zLjU5NzctNC40MjM4LTYuOTE3NS0xMS44OTEtOS41ODk5IDAuMDQ2My0wLjAzIDAuMDk2Mi0wLjA2MiAwLjE0MjUtMC4wOTMgNy41Nzc1IDIuNjkzOSAxMi4wNyA2LjA0NzkgMTIuMDcgOS42ODI2IDAgOC44ODYzLTI2Ljg1MSAxNi4wOTEtNTkuOTc0IDE2LjA5MW0yMy4wMS0zMC44NjFjMC4wNzEzLTAuMDI0IDAuMTM4NzUtMC4wNDYgMC4yMS0wLjA3LTAuMDcxMyAwLjAyNC0wLjEzODc1IDAuMDQ2LTAuMjEgMC4wNyIgZmlsbD0iI2UyZTJlMCIvPgogICA8cGF0aCBpZD0icGF0aDUzMjciIGQ9Im0zMDUuNTkgOTQzLjk1Yy0zMi45NDYgMC01OS42NTQtNy4xNjYtNTkuNjU0LTE2LjAwNSAwLTMuNTk3NiA0LjQyNS02LjkxNzUgMTEuODkyLTkuNTg5OSAwLjA0ODcgMC4wMzEgMC4wOTM3IDAuMDYgMC4xNDM3NSAwLjA5MS03LjM2IDIuNjUyOC0xMS43MTUgNS45MzktMTEuNzE1IDkuNDk5IDAgOC43OTEgMjYuNTY0IDE1LjkxOSA1OS4zMzMgMTUuOTE5IDMyLjc2OCAwIDU5LjMzMi03LjEyOCA1OS4zMzItMTUuOTE5IDAtMy41Ni00LjM1NjItNi44NDYyLTExLjcxNS05LjQ5OSAwLjA0ODgtMC4wMzEgMC4wOTUtMC4wNiAwLjE0Mzc1LTAuMDkxIDcuNDY3NSAyLjY3MjQgMTEuODkxIDUuOTkyMyAxMS44OTEgOS41ODk5IDAgOC44Mzg5LTI2LjcwOCAxNi4wMDUtNTkuNjUyIDE2LjAwNW0yMi43OTktMzAuNzA2YzAuMDcxMi0wLjAyMiAwLjE0MTI1LTAuMDQ2IDAuMjExMjUtMC4wNjktMC4wNyAwLjAyMy0wLjE0IDAuMDQ2LTAuMjExMjUgMC4wNjkiIGZpbGw9IiNlMmUxZGYiLz4KICAgPHBhdGggaWQ9InBhdGg1MzI5IiBkPSJtMzA1LjU5IDk0My44N2MtMzIuNzY5IDAtNTkuMzMzLTcuMTI4LTU5LjMzMy0xNS45MTkgMC0zLjU2IDQuMzU1LTYuODQ2MiAxMS43MTUtOS40OTkgMC4wNDYyIDAuMDMgMC4wOTYyIDAuMDYzIDAuMTQzNzUgMC4wOTMtNy4yNTEyIDIuNjI5OC0xMS41MzkgNS44ODM3LTExLjUzOSA5LjQwNjIgMCA4Ljc0MzYgMjYuNDIxIDE1LjgzMiA1OS4wMTMgMTUuODMyIDMyLjU5MSAwIDU5LjAxMS03LjA4ODkgNTkuMDExLTE1LjgzMiAwLTMuNTIyNS00LjI4NzUtNi43NzY0LTExLjUzOS05LjQwNjIgMC4wNDc1LTAuMDMgMC4wOTc1LTAuMDYyIDAuMTQ1LTAuMDkzIDcuMzU4OCAyLjY1MjcgMTEuNzE1IDUuOTM5IDExLjcxNSA5LjQ5OSAwIDguNzkxLTI2LjU2NSAxNS45MTktNTkuMzMyIDE1LjkxOW0yMi41ODYtMzAuNTVjMC4wNjg3LTAuMDIyIDAuMTQzNzUtMC4wNDcgMC4yMTI1LTAuMDctMC4wNjg4IDAuMDIyLTAuMTQzNzUgMC4wNDctMC4yMTI1IDAuMDciIGZpbGw9IiNlMWUwZGYiLz4KICAgPHBhdGggaWQ9InBhdGg1MzMxIiBkPSJtMzA1LjU5IDk0My43OGMtMzIuNTkxIDAtNTkuMDEzLTcuMDg4OS01OS4wMTMtMTUuODMyIDAtMy41MjI1IDQuMjg3NS02Ljc3NjQgMTEuNTM5LTkuNDA2MiAwLjA0ODcgMC4wMzEgMC4wOTYyIDAuMDYxIDAuMTQ1IDAuMDkyLTcuMTQyNSAyLjYwODgtMTEuMzYyIDUuODI5MS0xMS4zNjIgOS4zMTQgMCA4LjY5NjIgMjYuMjc2IDE1Ljc0NiA1OC42OTEgMTUuNzQ2IDMyLjQxNCAwIDU4LjY5LTcuMDQ5OSA1OC42OS0xNS43NDYgMC0zLjQ4NDktNC4yMi02LjcwNTItMTEuMzYxLTkuMzE0IDAuMDQ4OC0wLjAzMSAwLjA5NS0wLjA2MSAwLjE0Mzc1LTAuMDkyIDcuMjUxMiAyLjYyOTggMTEuNTM5IDUuODgzNyAxMS41MzkgOS40MDYyIDAgOC43NDM2LTI2LjQyIDE1LjgzMi01OS4wMTEgMTUuODMybTIyLjM3Mi0zMC4zOTVjMC4wNzI1LTAuMDIyIDAuMTQtMC4wNDUgMC4yMTM3NS0wLjA2OS0wLjA3MzggMC4wMjQtMC4xNDEyNSAwLjA0Ni0wLjIxMzc1IDAuMDY5IiBmaWxsPSIjZTBlMGRlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTMzMyIgZD0ibTMwNS41OSA5NDMuNjljLTMyLjQxNSAwLTU4LjY5MS03LjA0OTgtNTguNjkxLTE1Ljc0NiAwLTMuNDg0OSA0LjIyLTYuNzA1MSAxMS4zNjItOS4zMTQgMC4wNDYyIDAuMDMgMC4wOTg3IDAuMDYyIDAuMTQ1IDAuMDkzLTcuMDM2MiAyLjU4Ni0xMS4xODYgNS43NzM1LTExLjE4NiA5LjIyMTMgMCA4LjY0ODggMjYuMTMyIDE1LjY2IDU4LjM3IDE1LjY2IDMyLjIzNiAwIDU4LjM3LTcuMDExMyA1OC4zNy0xNS42NiAwLTMuNDQ3OC00LjE1MTItNi42MzUzLTExLjE4OC05LjIyMTMgMC4wNDc1LTAuMDMgMC4wOTg3LTAuMDYyIDAuMTQ2MjUtMC4wOTMgNy4xNDEyIDIuNjA4OSAxMS4zNjEgNS44MjkxIDExLjM2MSA5LjMxNCAwIDguNjk2My0yNi4yNzYgMTUuNzQ2LTU4LjY5IDE1Ljc0Nm0yMi4xNTUtMzAuMjM5YzAuMDcxMy0wLjAyMyAwLjE0NjI1LTAuMDQ2IDAuMjE3NS0wLjA3LTAuMDcxMiAwLjAyNC0wLjE0NjI1IDAuMDQ3LTAuMjE3NSAwLjA3IiBmaWxsPSIjZTBkZmRkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTMzNSIgZD0ibTMwNS41OSA5NDMuNjFjLTMyLjIzOCAwLTU4LjM3LTcuMDExMy01OC4zNy0xNS42NiAwLTMuNDQ3NyA0LjE1LTYuNjM1MiAxMS4xODYtOS4yMjEyIDAuMDUgMC4wMzEgMC4wOTc1IDAuMDYxIDAuMTQ3NSAwLjA5Mi02LjkyODggMi41NjQ4LTExLjAxNCA1LjcxODctMTEuMDE0IDkuMTI4OCAwIDguNjAxMiAyNS45OSAxNS41NzUgNTguMDUgMTUuNTc1czU4LjA0OS02Ljk3NCA1OC4wNDktMTUuNTc1YzAtMy40MTAxLTQuMDg1LTYuNTY0LTExLjAxMi05LjEyODggMC4wNDg3LTAuMDMxIDAuMDk2Mi0wLjA2MSAwLjE0NjI1LTAuMDkyIDcuMDM2MiAyLjU4NiAxMS4xODggNS43NzM1IDExLjE4OCA5LjIyMTIgMCA4LjY0ODktMjYuMTM0IDE1LjY2LTU4LjM3IDE1LjY2bTIxLjkzOC0zMC4wODRjMC4wNzM4LTAuMDIyIDAuMTQzNzUtMC4wNDUgMC4yMTc1LTAuMDY5LTAuMDczNyAwLjAyNC0wLjE0Mzc1IDAuMDQ2LTAuMjE3NSAwLjA2OSIgZmlsbD0iI2RmZGVkYyIvPgogICA8cGF0aCBpZD0icGF0aDUzMzciIGQ9Im0zMDUuNTkgOTQzLjUyYy0zMi4wNiAwLTU4LjA1LTYuOTc0MS01OC4wNS0xNS41NzUgMC0zLjQxMDEgNC4wODUtNi41NjQgMTEuMDE0LTkuMTI4OSAwLjA0ODcgMC4wMzEgMC4wOTc1IDAuMDYyIDAuMTQ2MjUgMC4wOTMtNi44MjEyIDIuNTQyNS0xMC44MzkgNS42NjM3LTEwLjgzOSA5LjAzNjIgMCA4LjU1MzcgMjUuODQ2IDE1LjQ4OSA1Ny43MjkgMTUuNDg5IDMxLjg4MiAwIDU3LjcyOS02LjkzNSA1Ny43MjktMTUuNDg5IDAtMy4zNzI1LTQuMDE3NS02LjQ5MzctMTAuODQtOS4wMzYyIDAuMDUtMC4wMzEgMC4wOTc1LTAuMDYyIDAuMTQ3NS0wLjA5MyA2LjkyNzUgMi41NjQ5IDExLjAxMiA1LjcxODggMTEuMDEyIDkuMTI4OSAwIDguNjAxMS0yNS45ODkgMTUuNTc1LTU4LjA0OSAxNS41NzVtMjEuNzE4LTI5LjkzYzAuMDczOC0wLjAyMyAwLjE0NjI1LTAuMDQ1IDAuMjItMC4wNjktMC4wNzM3IDAuMDI0LTAuMTQ2MjUgMC4wNDYtMC4yMiAwLjA2OSIgZmlsbD0iI2RlZGRkYiIvPgogICA8cGF0aCBpZD0icGF0aDUzMzkiIGQ9Im0zMDUuNTkgOTQzLjQ0Yy0zMS44ODMgMC01Ny43MjktNi45MzUtNTcuNzI5LTE1LjQ4OSAwLTMuMzcyNSA0LjAxNzUtNi40OTM2IDEwLjgzOS05LjAzNjEgMC4wNSAwLjAzMSAwLjA5ODcgMC4wNjEgMC4xNDc1IDAuMDkyLTYuNzE1IDIuNTIxNS0xMC42NjYgNS42MDg5LTEwLjY2NiA4Ljk0MzkgMCA4LjUwNjQgMjUuNzAyIDE1LjQwMiA1Ny40MDkgMTUuNDAyIDMxLjcwNSAwIDU3LjQwOC02Ljg5NTkgNTcuNDA4LTE1LjQwMiAwLTMuMzM1LTMuOTUxMi02LjQyMjQtMTAuNjY2LTguOTQzOSAwLjA1LTAuMDMxIDAuMDk4OC0wLjA2MSAwLjE0NzUtMC4wOTIgNi44MjI1IDIuNTQyNSAxMC44NCA1LjY2MzYgMTAuODQgOS4wMzYxIDAgOC41NTM4LTI1Ljg0NiAxNS40ODktNTcuNzI5IDE1LjQ4OW0yMS40OTUtMjkuNzc1YzAuMDczOC0wLjAyMyAwLjE0ODc1LTAuMDQ2IDAuMjIyNS0wLjA2OS0wLjA3MzcgMC4wMjItMC4xNDg3NSAwLjA0Ni0wLjIyMjUgMC4wNjkiIGZpbGw9IiNkZGRjZGEiLz4KICAgPHBhdGggaWQ9InBhdGg1MzQxIiBkPSJtMzA1LjU5IDk0My4zNWMtMzEuNzA2IDAtNTcuNDA5LTYuODk2LTU3LjQwOS0xNS40MDIgMC0zLjMzNSAzLjk1MTItNi40MjI0IDEwLjY2Ni04Ljk0MzkgMC4wNSAwLjAzIDAuMSAwLjA2MiAwLjE0ODc1IDAuMDkzLTYuNjEgMi40OTg3LTEwLjQ5NCA1LjU1MzgtMTAuNDk0IDguODUxMiAwIDguNDU5IDI1LjU1OSAxNS4zMTYgNTcuMDg4IDE1LjMxNiAzMS41MjggMCA1Ny4wODYtNi44NTc0IDU3LjA4Ni0xNS4zMTYgMC0zLjI5NzQtMy44ODM4LTYuMzUyNS0xMC40OTQtOC44NTEyIDAuMDUtMC4wMzEgMC4wOTg3LTAuMDYyIDAuMTQ4NzUtMC4wOTMgNi43MTUgMi41MjE1IDEwLjY2NiA1LjYwODkgMTAuNjY2IDguOTQzOSAwIDguNTA2NC0yNS43MDIgMTUuNDAyLTU3LjQwOCAxNS40MDJtMjEuMjctMjkuNjJjMC4wNzM3LTAuMDIzIDAuMTUxMjUtMC4wNDYgMC4yMjUtMC4wNjktMC4wNzM4IDAuMDIyLTAuMTUxMjUgMC4wNDYtMC4yMjUgMC4wNjkiIGZpbGw9IiNkZGRjZGEiLz4KICAgPHBhdGggaWQ9InBhdGg1MzQzIiBkPSJtMzA1LjU5IDk0My4yNmMtMzEuNTI5IDAtNTcuMDg4LTYuODU3NC01Ny4wODgtMTUuMzE2IDAtMy4yOTc0IDMuODgzOC02LjM1MjUgMTAuNDk0LTguODUxMSAwLjA1IDAuMDMgMC4xMDI1IDAuMDYyIDAuMTUxMjUgMC4wOTQtNi41MDUgMi40NzYxLTEwLjMyNCA1LjQ5NDYtMTAuMzI0IDguNzUzNSAwIDguNDE1IDI1LjQxNCAxNS4yMzMgNTYuNzY4IDE1LjIzM3M1Ni43NjgtNi44MTc5IDU2Ljc2OC0xNS4yMzNjMC0zLjI1ODktMy44MjEyLTYuMjc4OS0xMC4zMjYtOC43NTQ5IDAuMDUtMC4wMyAwLjEwMTI1LTAuMDYyIDAuMTUtMC4wOTIgNi42MSAyLjQ5ODYgMTAuNDk0IDUuNTUzOCAxMC40OTQgOC44NTExIDAgOC40NTktMjUuNTU5IDE1LjMxNi01Ny4wODYgMTUuMzE2bTIxLjAzNC0yOS40NjJjMC4wNzc1LTAuMDI0IDAuMTU4NzUtMC4wNDggMC4yMzYyNS0wLjA3MS0wLjA3NzUgMC4wMjMtMC4xNTg3NSAwLjA0Ny0wLjIzNjI1IDAuMDcxIiBmaWxsPSIjZGNkYmQ5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTM0NSIgZD0ibTMxMi4zMiA5MjcuOTVjMCAwLjk5NzUtMy4wMTEyIDEuODA1MS02LjcyNjIgMS44MDUxcy02LjcyNzUtMC44MDc2LTYuNzI3NS0xLjgwNTFjMC0wLjk5NjEgMy4wMTI1LTEuODA1MSA2LjcyNzUtMS44MDUxczYuNzI2MiAwLjgwOSA2LjcyNjIgMS44MDUxIiBmaWxsPSIjMTAwZjBkIi8+CiAgPC9nPgogIDxnIGlkPSJnNTQyOSIgdHJhbnNmb3JtPSJtYXRyaXgoMC4xMjUgMCAwIC0wLjEyNSAtMjY4Ljk4IDk1MS43KSI+CiAgIDxnIGlkPSJnNTQzMSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTQzMy0xKSI+CiAgICA8cGF0aCBpZD0icGF0aDU0NDUiIGQ9Im00ODI0LjcgNDM3Mi4yYzAgMTA1LjI2LTg2LjEzIDE5MS4zOS0xOTEuMzkgMTkxLjM5aC03My40NGMtMTAuNTUgMC0yMC45MS0wLjg3LTMxLTIuNTN2MTEyOC40YzAgMzAuNDYtMjQuNyA1NS4xNi01NS4xNiA1NS4xNi0zMC40NyAwLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTExODguNmMtMzEuMDctMzQuMDUtNTAuMDctNzkuMjgtNTAuMDctMTI4LjcydjEyODkuN2MwIDEwNS4yNiA4Ni4xMiAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtMTI4OS43IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTQzNykiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzU0NDciIHRyYW5zZm9ybT0ibWF0cml4KDAuMTI1IDAgMCAtMC4xMjUgLTI2OC45OCA5NTEuNykiPgogICA8ZyBpZD0iZzU0NDkiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDU0NTEtOSkiPgogICAgPHBhdGggaWQ9InBhdGg1NDYxIiBkPSJtNDgyNC43IDE0MDAuN2MtNzAuMDkgMjkuNzYtMTQ3LjE4IDQ2LjIyLTIyOC4xMSA0Ni4yMi04MC45NCAwLTE1OC4wMy0xNi40Ni0yMjguMTEtNDYuMjJ2Mjk3MS41YzAgNDkuNDQgMTkgOTQuNjcgNTAuMDcgMTI4Ljcydi0yOTE0LjFjMC0zMC40NyAyNC42OS01NS4xNiA1NS4xNi01NS4xNiAzMC40NiAwIDU1LjE2IDI0LjY5IDU1LjE2IDU1LjE2djI5NzQuMmMxMC4wOSAxLjY2IDIwLjQ1IDIuNTMgMzEgMi41M2g3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTMgMTkxLjM5LTE5MS4zOXYtMjk3MS41IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTQ1NSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzU0NjMiIHRyYW5zZm9ybT0ibWF0cml4KDAuMTI1IDAgMCAtMC4xMjUgLTI2OC45OCA5NTEuNykiPgogICA8ZyBpZD0iZzU0NjUiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDU0NjctNSkiPgogICAgPHBhdGggaWQ9InBhdGg1NDc3IiBkPSJtNTE4MC43IDg2Mi43NWMwLTMyMi42NC0yNjEuNTUtNTg0LjE4LTU4NC4xOC01ODQuMTgtMzIyLjY0IDAtNTg0LjE4IDI2MS41NC01ODQuMTggNTg0LjE4IDAgMzIyLjYzIDI2MS41NCA1ODQuMTggNTg0LjE4IDU4NC4xOCAzMjIuNjMgMCA1ODQuMTgtMjYxLjU1IDU4NC4xOC01ODQuMTgiIGZpbGw9InVybCgjcmFkaWFsR3JhZGllbnQ1NTAzLTgpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1NDc5IiB0cmFuc2Zvcm09Im1hdHJpeCgwLjEyNSAwIDAgLTAuMTI1IC0yNjguOTggOTUxLjcpIj4KICAgPGcgaWQ9Imc1NDgxIiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1NDgzLTIpIj4KICAgIDxwYXRoIGlkPSJwYXRoNTQ5MyIgZD0ibTQ5MzkgMTE3MS44djIuNDctMS4yMy0xLjI0IiBmaWxsPSJ1cmwoI3JhZGlhbEdyYWRpZW50NTUwMy04KSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNTQ5NSIgdHJhbnNmb3JtPSJtYXRyaXgoMC4xMjUgMCAwIC0wLjEyNSAtMjY4Ljk4IDk1MS43KSI+CiAgIDxnIGlkPSJnNTQ5NyIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTQ5OS02KSI+CiAgICA8cGF0aCBpZD0icGF0aDU1MDkiIGQ9Im00NzU2LjIgOTgwLjg2YzEwOC43NyAzNi4zODEgMTgyLjkgMTA4Ljc5IDE4Mi45IDE5Mi4xOHYtMS4yNGMtMC43My04Mi44Ny03NC42Ny0xNTQuNzMtMTgyLjktMTkwLjk0bTE4Mi45IDE5Mi4xOGMwIDQ0Ljc0LTIxLjM0IDg2LjMxLTU3LjkyIDEyMC44NiAzNi4yNC0zNC4yMyA1Ny41My03NS4zNiA1Ny45Mi0xMTkuNjN2LTEuMjMiIGZpbGw9InVybCgjcmFkaWFsR3JhZGllbnQ1NTAzLTgpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1NTExIiB0cmFuc2Zvcm09Im1hdHJpeCgwLjEyNSAwIDAgLTAuMTI1IC0yNjguOTggOTUxLjcpIj4KICAgPGcgaWQ9Imc1NTEzIiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1NTE1LTYpIj4KICAgIDxnIGlkPSJnNTUxOSIgdHJhbnNmb3JtPSJtYXRyaXgoNjk2LjIsMCwwLDQ1My4yLDQyNDcuOSw5NDUuOSkiPgogICAgIDxpbWFnZSBpZD0iaW1hZ2U1NTIxIiB4bGluazpocmVmPSJkYXRhOmltYWdlL3BuZztiYXNlNjQsaVZCT1J3MEtHZ29BQUFBTlNVaEVVZ0FBQUpFQUFBQmVDQVlBQUFBalp2WkNBQUFBQkhOQ1NWUUlDQWdJZkFoa2lBQUFHWHBKUkVGVWVKemRYZG1TNURDcWhlejgvNys5RWZlaHk4eURCVG9jSVZuT3JLenFHU0lxeW91RU5nd0h0S1FlLy85L0p2L05wUC9kMVE4eS9lMGF2RXhQK2Z2MU9lNlBiK0J4MWJmL0l6SjBTZC9SenVNYmVCVDAvQmhua2M1NkVJUWJYNTEzM3YrS3hybExMMmtvVzk1K056M2wrS0FtOHZiZmJVUWxNTi9kRWZvaDgyRS9JT3l2bXI0UFZlMHA5a0Voc3UvNWtENUMzMkZxSy9xZ1luK0hQcW5Jbi9ZT0psb0l5QjNoMFhkYStIanhxL3dYQi90NHJSOXNvN08zVU1HTHcvQzBOOHlacWt6TmdwcUlQUFpxOVlvRmlHTC9SV0Y0ays3MngvWm5kS3krZW52WkVqL2w2KzlyT1IrTHh0SUFKemtyaEc3V3ROVUhkdG5nZnhXSXYrSEszMm9TZFZDNlhmRjU0YU44aXIzNEtjOVU3ME5HNXdEdmQvclFCZTBWT1hDczg0L0tVTkFMM1I1TjJsRVpWMGxtNWI4ZzVFLzVlNkdKS3A3RkoyRStlSlYxWE5XckFyaFZCNEFHV3pienlqcHZtdGkzYVdVNkxpalZjTmZHcklSeXdVSjNoV25CNHlWTlpCWEhMNmtGUWhjVlVMbitJbjNRZ2NkV3R3TGdUakwvU3hncXh1UkY4THdsbEMrQTVrcEcxWXBCV3duUmNTdzBFZkVLWlRBelQ2NEZac2FiaFl5VFZmbFlzK3g2WTVBdmNmM0oyWVYzbGQ2T3dLM016eDJUVlh5a3BSSXNudDNTUktNRzBzbVlGQ1VWV0ttekdiWE5tS2I5MzYzdUZLMXY1djhrM1FMSU8ya21qWjFwK2lMOUtUQ3NmZlpNMmxOYm5NaFFDekE0WmFBTGFVTmFzVHhLcHlKWm8xeHBwQVhaYnBEd1g1c3VlUUd3VHZIS0RrSGVEc2pYR3FnL1krR2hSTUJiVFZFVFFjSUIzMXdNaENxWnVLeFpzam14N3Zvemp3VlpnWTNHZWxRWmwyeC9seTdxWmlxaVcxaklhbll6d2IwU0VtYTIwRWdtSWsrenIvTUtoUVlHZW1tR25EL2pwdEs3NmtLUWhhZW90QlBVcVp4TzR6NjZJekNmMGxEZkhRZENkaGNlMkxRcnArWnJVblpWRHl3YitSMHVSSkJJMWJJcGNET0VRbVlTbWlOa28xMU1ZMExXK1djWHR2TWZPakdBZXRFb2VMeHQ0aEw5TzBLMFpiWm1IOUVrNzNRcVpPcU5SY2I4Y2lZOFVNWlQwRHRUN1R3ZUlvb00yVnp4SzN4ZkJmd2NWaVd0QlRjSHRZOEVJd2tZeFhwbVNzVldKdklYekp6T1lqNWNUVEJoTVc2VlJocUN1cHVZQjNsVWhIeEFVRTJVaE9wazNwZUN1Qm55L00zS3hRZ2RJZ29EYXp4eU82NytneVY1a3E1NFZHcXZDOEl2L0RWdDlUNmxPbnpIMHFBN2JqdUhDQ1pDbHZVRENQQkNDL1U0UUxqNFpNS2NFQS9oWEprTG5CWHBrQWUrRjA1VFZBaTlQdW9zWlhOYWtUZTBhU3FyeXZoaEtnVUh3ZklGTmx0R1lLNDB6eXdzd3ZsWTBIR2Nqem9kOG5sYU0yZUtBdy9hU0hBNkE4dEdrM0kwVHdJYndwcXA2cXlacTcrRm9ndmFpVGVWK1c2bVozcW52S3QyelV6Z0xOK1Z3QkFNR1hHb2xtbXpHY1BxYVYrVUZ1T0hlQWMxREVvb2FSNlgzbVQrQWtoVHdUT05valkreC9ZVVg2UytFVy82bG56ZlRKZHgzNnFlTENRVDdUUElZc3MzZkxlc2ZTYlBBMytaQTJzWEFqMExDNjMwSmFJSVRwTlc2clZTQWN6UnhGdG5EdWRzanExMFA2QUFKSy8vWmpEdVV5dGhkK24yT3AxU1dHWnA5N3d3RGFFSmQvcDh2dEpjU1dnQWFuamU5dWpVUkNiZHhUYnRwa3A3QitoRUs5a1EzVGJRU3RRWXRWTW9WL2hwRmpDc05GVkZGVXhZcGY4dXdIMDN1cndyV010RlZZdDAyZ1orQ29nMVpkT0o5ckhpdVZMWnozQ2xEa21lbUttZXQ1b2xONWhGMnBVSE5ycFlxVTBVS2hCeHJWRUV4NjZXY0t5Q2xwanN1MHpnckh4bnV5dFVWL1V0STliTmNaaVlxQ25mcEdVbW50c3NtbTBLZWlKam1YRHh6VFVORElaakhRWFBMR0VjdFM1amxRZUcxMlR1OEQxT3pRMGRzMXBrdHRCT1V4UDJRMHRCWnNVdnRXSVZXYjZJV0d2RmMyS1Nob3pDR2dqZGV6czdFYlVSRHB2MS9LY1FxWWgrdGErZFBTc1JzYStPazB3a3RJM0N5T21YOU9CZUZVUEN5cEpXTVpNaGhqUUkydUpkVFM4Q29kMUZheTh1T3BzR0hKRndNRk5tR1FUdEhGRGl5WFZiYXF3UjY4Uno0MmVacDJ2SnA0ZUtrd0FaNVhwbzZYbWx4dmx6ampQNGN4eWNDdi9nWEIya0tSZjhyL0FUcDBIYXdUKzc1bTFIaHI0Qko2bElDTVdWVnFyN2RXSGlIRTNnMENUY3ErY0FvT2tEcjh3bmlMdDNkblNCR1VvZGtMNmNBMnNpd3d3K202c1VjNEtFbkUrQUo3SXJjRlBuVVR3S3BGaGsraGQyaGdTZ0xlcDMxYzVoT3VMQ2k1aHBwREpJZWI3VVEvdHI0LzhGcGpLVnA4a2hLaWUyc2NvRGMxTUVBeHhZQ2IwMDRZQWxBQjBSV2E0TUFFQWY3N25oa1JhdUQ3amxmcm5TS0wrOGVWRnBnSWJxcnVwL0ZWRGtOQmZtUy9FVkM5QkI0K2pYMnZIVVUrMXZwRGh4MGFUaWhTMGZ0S05MODZOdzQxTWxMa2E4WFBqYkc5TUZadzZzZzZidG1UeC9sMTRFOUtmYm5BT0E1MDJsc1ZqRDBQM0ZuSm1sOTRCc1dmTmczdkxkK2E4dkJSRTVZMFVIYUNRZmZEUmRRdThFbm92UVVoTDZQNlBaYktzV1g5Q2ozQ1lBdk9oK1ExamVEVVp1QlJOMzZqWERNbHpZd0d1TUVaVmxPYzcwWVl5cGhnTHpZRm1oalpybWF0TmNucTFOZTFpOFRJVkdnK0RCUThZZ0lsUStMZlh3Uk53aERKU3JCZW1zeldacE40Vm1pQTl0NU5tbEpiNS9OVjYwWElDLzFqUWpGaHBOMkZBbVgxZm1FSVJSRFlWSWVzUTZxVFhYS0M0d0QzakptaUtseHdvMzArWTBpMDRuL2xnSEdZbWZNeStxMnBDdVpQbStiWnR0V2FpMDNPVnUxRmwxcXJiTzVybEUrdUQ3TUZWZUZsN254VjZaUDREcm1PbHZlUGxjQ3VLZzk5RkhWVTJHeElQM2hJTlBKazZINlJDOHdUeFVXU2w0TXBYbWpwSU1nUHkrb0JqRnJuU3hoV2Y2NXM0aW9wVmRYQ3pmbU9mTkdrUzEwSXhXOUgrVVFmbGRCMFNlODhGVDdHL1dPSUw1SUxjcFRKNFdnQmJ6Vi92MFdiZ3F1cnNZZjVsdWdyTmtGSTRwWFZpTkZhMEVib3ZScFA5NkFlMy9JZzVVbWxKWENKekhLRkdSeGtRYUdNclBuMlpITzkwRHpCZG1UcTQ1Z1I2Y0trRmhpdlE5cmFMcDQ4QmpkSWhjYnlmQysxVjBHUVNhNlVvcFhZM3hsbEtyZUZ6aG96dFI4RUdqeUNCWXFwSm0zMU1abFZBaVQ4M2Fab2hMV1gvMkZQbDdZaGNFUko2QXRNc0FodzdKTVorMEZrbVRPZXFyQVZvYTB4eFh3c3JGNWRWb0Y4OTRITzRHR0hWVFNONkhVZGZhaGlqaHJzcktINjN5MnE3VHl3TDM0UE5qZkowUWlNRWY1RFZ6NzB6MWhFWThkell4VHhGa3hGQ0EwUFVnWU43UUhwUThnVGYzUkUrYmxPSXdIemR6Z2VINmhjTWJ0azNXcnJBaFhXbWF3SUtnd1dkTFlGbEdBQUFQQy9hZDNUUUFxYUdKWXVoZEZGaERNVTlST202dndFYkJVZHdrZ2JhSnFaTGdCNEpuV1lEZ09vVWxBRHQwellRZUhWZmErUmNORThrald6VCtFZy9kRUl3bEpIdG5IMzIxZXpYZCtFZFllRmhWK2dRMUp0cnN5RHhVdEp2Q0ZDdnFlVjF3dTR1UGd4VmVtUUhMWmwvVEpzZjJ6OU82ZHZKQ0VzK3VyWklTU1dWN3BhbWhkelFLQ3VqdXNUVWZvR3BNdDdkRkR5WjVGdmVaNEJ0TWdvTEIrTE85dDdKTU1KOHdUemFVWnlKUFM4ZG5XTm1HcERwb3diNnFaSzNqdHJWWW4xUVNtb1ZaSEtucS9KbGdZZDBXZy9hcExVUzM5czlYVGJneWVRZ3JlSnJFdTYvaTYrYXBXcWw0YUJtN2l1RWo0WWwxYW5LYXdYTTkwUU5BbEJJWDNNcnNiL3lnaHFScHN2ZDFSZ1dzN2lqU1JKRjlKa3czQm53YjAzeElJOTBLQTF6MWpkUFZ5UjZiQUYxeFMwNllweE9JTy9ZNVk0T2Fxekxnb3ROcDBwYjlLZkwzbEU3Y21BaTE0QU9QVkxvdGpLK1pURmxJZTdXc1pBRHBIV1FQd2tKYWJEQ0RCZWxzZXhMUlN4c0pOMmpQczlzcmZEaHhCWG5Ud0JyanlBTnZHald6WkVwcFVOTlkwWVltZkZhWXRYTmxvMzJCNmVuQVdVRlFrOGZVek0rd2lLM3hVQlVwdnlidkFWcnVZWWlST0w3RXdzT2RPS0V0SmJQSmE0dXN2SnpTc2pqOHdQd1JNeVdUbDk0UFdtdDBVclNsc3lSSVdDQ1lOL28vWUMyMTVwMGxkOXkzKzdUUlJkQ3NQdWdXMnNhOW5XUnIzZHk1b0hEWFRtTkl1Y0tWOEJpYnVpZ1RyaGY0cTZSUGdlMEpYOXRJSTlJeHp5Qk0vb0ZYVXhncXd3UnRCOGc1TFU2aWp0NmtnZ0IxamVJbUxKNGZ2TnVqYlJzeTFrZ3c0TjRZSmF3VWhHYlI2dWR3UWdCbGJzSVhXblBpdHBZMlkwTVM3Z2pMU2wxOHE5QlZiWFRBVE1MZzV4QWxzRkt4SkUxMTJQalloY2N2RVdJVjhhUkJBOEhnbnN0andiVEVxa1hweUQvNFY5ckRZeFpVT1c2UG9hbWFmYUZmcDNCMlhuWEMzWDFrYjYwVCtvQjJHczFTblc2dU1NbkZ0dlNTZUk5YXJOS0FHZ0NadEJXdXBZWTgxbDJ6dnJMeFhHR0VwUW1Zclg0L3htc212dnZSMDVXSG9FODFDYlVPTGt0ek9DRXQ4aS9wdXdEMlJsbGMxT3pvR3lzQ2xmVlNrMFhsTldzTVZSRGlJb3h3dnRmekl6eWFaRkdBY2NCSjdib2ZMZk93ZW8xekNKVG1EWTdUQVpac0J2MGZDaUhhZHIvMmt6d1NPMnJzWkphL3NJZzFWYkdsVCtFaHAwWGNaL0laam01NHBDa2F2dG9lZEdqU0Q0Rm5BRkFQWm9vMEVXcWV6THZwZzNNQzFvT05CbmlJVFJocnBrWFArOXhZQ0FmSVN1VjlsYTdYbkhkb09YYVRmK3FRODd0MHBlbU9yR1BDY2RpZTBSOHhDdVpOWEhCdWpDdUdYdllYNDZDY0pFN1Zhek1ZcDNjVzNwS0ltemRja0thTmd3bDVVQ2hVQnZLZ1VHcFVjdElwWmdOT211RnBaRjJhVVpHOTVTSGZaY0pXZENYVFIvM3hkRVhmUHBhS2RZa3EwSlczT2pGb3VNSEx3bm8vR29ZRkdUQUtCZWpSdmJmdTRvdUkvWkZ4OXQwazRrZ1BsWDRpQ0MvbTF5cTIwSlF3OE11UmFXOFVkTXF3b0swbGNuTW5CVjI1OXdNWXFaaDhtTGpNelRyYlJOaEVwRjVUNUplbzdEbndtRENOSjRRS2ZXbU9GSEE1QWFQTjF4T2RTMEY4WDMwRW9HWXVQRFlnTFVxekdyTW9ORUpsN0R5S1V2TXlocGpadnpOL3hvVEpmdXJZdlZjMlN0NDR2RUhGY2w5U2VieXZMVjFuKzlsQks0NlR3UnBFMTB4UVRwL3ZWRGlDMkh6QXRGZ25CTzQrMms0MGFWNUF0ZVVaUFRNOG53Z0FYa3lacEt6MWIzRDFHRlVOdEhOaXV2K3RYYkE3OGc1MmZMWkxaUERTcXFXdXcvV2tjRDRWRGNjRDZtQ29yVHlOV3huVDd1S3J1S3djZzBuRFRRTjQrSUppNFpHZzM4Y2w3NVVLVFdmRnMwNG51NVd1bDR0d0FSUnhvWUZlV01mZmkxaGhyTzB0US8xcjNIYnhaMU1jay9YUlNzK1NSKzZQMFk2NUpzTHFSSnk0QS9xbkhWOW5qSUJYSWVLZ3R1dncwQTdwYTZaUmE0SHdKVzJhOXVERGkwSkxxT1owS1VtMXgzODErTVYyN0hlRVpVYmxoaFJxaDRqc0NaUkJINndXbWtuUmRCeS9BWGRtZDc5YTVhaEgxem85cGxUVUh3WExSSjdxSTg1Ymd0S3Mvdm5lbzhtdWtSUk5HZzN3Mlk1aXBMRWxJS2pENzhCV1FsWUp6NFVXK0syajlvYm1YQWt2djYvU015NVN3SkFyQVQxOE5JcVBHUU9LVGZoQ1BvNmNsTTJkNXoweFVlRzI2MkZ0dHl1Y3Z1Z1RjTjZBQXdTSnBqV2l1a3J2c0RPS3dHUnFKQUgwbEhmMTY0ejhBVERmbjZDcVBVNnJBUjhXMHE5NUo2VTFlRkVaOHd6ZkpianBJdWQ0QnZ4eFpZQnpaWUdSTk1uS1V4eVZ3d0JyY1paUUhIZDNhREpQQmhjcHRPNHRKSUhCVmlRUDRzcHJZczFUN1VsM29wQkJ5UWZwT3dUclN0TnNtckpMZnZ3Y3RNWDR3Wmd3UGpKNGxkaVJoamt4c2sxTUtuekFoN2JOaXltTzB3citJMm5nZklxdFl5Zkw4YVJ1dzhaV0ljYUtjaVFMenlBa1k5Mkh2WEU3ZUdqUUNCV0FXZkI1bFZhSExGd0pWQkttaVlRUG1BY2NueFcvZG0vaTVzb0g1T1JodVBQREtML0tHUkdTckkyU0psTE1BYTUzdUhqOFZTZE1vNk1BVmU0MUw4V2RtVHJNVzJHajZqbVhoWHc1NzR6dWFLVTd3cmM3alhGbDFxczBnR3RTOGxrd0VqN2tZWE1qbEl0aGhqaUExT0VPSUt5bnlOOWVvRWVpUVV1a2hWQXVOR0MybERFTkNoRCtGOG1lMjQ0bW1peEE0K20yVjA3OG1McmwzNkNWdHRkT2U1RTAyRk1RUGx0WGpSL2oxWE1XcEZaZWRkUnd1bVplb2ZtMG5kbklsUTl3WlhuN2N4SWtUNlI1a1JvT1B1WmpjZ0dhZldXRnA2aG9pNzFoMGp0QWIwekNmc0xWdjB2akx0WHpYd2ZMVU1tSUdFK2tzUVRXbVc4dnB5clg4cnNqNXp0YWZDZk5QalJ6MkhiQXdndm45UWNxZ2dJQzF3b1lwUThrVlJ3MURuOGxLNkk0VlVyT21BZ1cxZDJsN3dvQnZGSjJDblVrWnUzMUN2eE9LMEw4SjRMRW1pZHdqcWZGOUs1MVN2UXU4cFJ3NWFFd3QzdnF6MmMyNFh5L1BBdklCYWlJU0pmUG5LSU9DeUE4RzRRN1c0eCtTeU5WQWpHSEp4M3d6dEpWL0k3NXUrRVFPaE5SbVp4K3dPRVVHdDl6QXBhbk9mNklQQTdyV2crRkRBYmUxTEpiWDBpcTRqSVRuQ2ZqWjVHaFYyNzRLUWdtYnZIVUl5dnkvalRONnJUcHFha3NjQXMvNDNRcTVWUUs5dTBwU0pVNzMzbWVzeVFOVW9OWmZmWjRTMHZkRnV1ZmdTWVIrME02bGR6MUNEaHl3N1NkK3VIOEVSdnhjaE9lTStNdkxnSDNoZXFZeFlkK1dxaFcydTNXWEZybW1WYTh6a0lHbGFDWjVPZzJxZDh1bkRZMWY0NUhINkd0ZW9LbnlGYytVaG9xR3dVbWQ1dUFOZ3RTSkN2QU5XSXZKQWgyWHBxdlhWeTFNbkUvYWNMdXJCcFlhUmVnUE4xQm5VQnJ3VEMvcW8yZUlNK0h4UWRNNHllYTB6bFBheWZxdXhBa1FhMjBrR2pmbjBUSDBDQ3dUc3RqZTlaUkE5Rzc2UzlaN3dRa2R5UGVNM3BITzYxNDd6Z1FWL3dxVENNeWVOTmxIdFlvVFFBSDB4aG1xMWY0NU4vMGpzcnBtZkVCRVJKbk5uWnRFa0xYTWhuZ0lmN2gzNzR1aU5RamFqT2VlVWRjaEkxZ2JDVkZPa3pQNzNjMTFJeCtVanRWZFYxcHJJZlVab3F2bVUvU0xPZEZDRWF5TnVEV1Z4MWhkSU5LNmhCNVduUDh3NlJoQXA0T3FXd3hyVWtKemZTUU9nanAxNHhmSnZqb1Z1ZXV6bno4TFhDOXhFZEZwWGJNSC9mZkxESk4xN2k4STJteXhEamZ4ZlNHOHdLdDVYdlpubkljZmEyUW8ybG5ja2pmY3k4eWdsb0tDeWhjeXdHQ2hJM0J1TkZZNzlydDV6UXpnU2dHb1B3SnJRWHR4bzFlaWtrTjlTdVlyQUt3VGhYZU0zcEhlZEw2b0VySXFqSlJnRVNrSGdocjVzenhDR09adHVkZUZVeGhNam1nblR4cnpLVlI1WHVaSFFOVkI1N3p6RFBqSk9ya3l3TTJGbCsyVnBzZ2Q0V3RlRmE2MFp1c0IzOWlwVjFFeW9iM1V6dG1lUUN1bE9aeEhJK0hJZllpcmRTV2tqeEZqcndZbnBldFJnWVpnNUt1VVZ3d3FtVVgxZlloazdZWjBrRzY1UDhySWt3MUxQNjY0SkZtUm5hT3hMdEJnMUpkQ1BqME1Lelo4MEVKd01lR3kwRjRpeEFLQUJKREFjWkRiY3hCUjhBN0FOZnFFN0FKZ0FsZm5KMHhjNjhkUkx1Z3RRcW16WTVFQTFaeUFaM0ZlVVlPUTJlSDhGeEZaMWRzdjVuNjRlSGp1MmgrS1RTTFdsN01qNVZZaCt0QldxZzZKUzA1UWdrVDZaRHVxWTZPU2svTDRwSGhZalFZbEk2RFR0V1cxbXA3Y3Y2Qm1hS0JnNG5DaVQ0a3BjUk94V0E4YnVLaGltSkg4QnZ6SStHQVZaaHRXdkQ4UGcwUm15WnhOOTd6V1pyb0RYTW03ckpiMWc5NE0reDVBOXlDRVd2ejFVZFFxRFNBamVPSFo5a001THhwd0VNVkhoS2JtQjVGRENrSlRacnFJQnM5aXlNUjNSV2VKYTRpbURHam5WVUJEc0VxWWJvcS83eTIvb2dGaDhwWEVUbXNRNGFoZXFSaGhyaVRpd0dVWXlJU1B5K2tFb0lVWnpibUw5NzZpYkN6aHFFM0ZqY3llRmRoS1EvclAwaU1HaW5GaEZpNkp2YWQ4eUdMRFZ6MWlhUDJacnVZSy9JNmxzSlU1YTJlUFVUR0F4Lzh4akh1UW9BNEkyNlpiY1NtVmcybWFBRkRQUWVSUm5ETkdBWEFOWnhPUE5iU0crY1R0WkpWYk9TdHRpbFZWQzBqNFRKM3ZuSlhCUjhHUnNGKzZ5enJ6ZWV6ZGxtZHhyL3Q2VkY4RG1OWUczbG1BTlVKZDltSVI1L1ZWc3M0NzlqL280WUlRZUxQR1U0VmFhZEZKTUF1K2F2SlA2Nm5XWmlRcm1KR3lIUkcvRHNWdjBGM01WVVYycGhHclR0V0hiZWh5K214SVZBV3hIamEvMW5qZGFRMytjcG91WWpCcjFGSEljMUdKZStxblYwZCs4N2NMdExobjJuUjBxRkprTkpVQ0xSNzJDS1NnQmgxVnFHQnRvS0R2eWc3ZCtoeWtYM2wxUzR5RDNOclIzOHhvSlVHbHM4aWJOUkEwcUZReEpQYTgyZmNFUkFQUlJTQ0pTMG9DZXFsaWk1akN3SlE1N2JrZ2FmR0I4QmVnK3Jsc1VhdllKNGJDOWtTM1FISlRoT2hMdDF6VHIraFRVdVhuUU9Sek44eGpqVXRSUm90ZitmV3ZUeVRIckVXa1hNdEVaN0phSEpPWFlSa3RTczNWMUNwWWVsa3UxZkVSeURSSWtLTHptamswMCtHR2dtUVRnZGkxNE9MdEZIZVpwNFYzZEY0UzBFcVhxNStFOVl2cXpZZ1RyTGNqMGtiaFRueld3dmU0M21Pa3JEUnMzT3ludkRCM3JWelptelVnV29jQUFyQ29CaFZMR2J2MDdyczJKWkVuY1czc3pnUlZYVkt5TytUWnU0TjNzTW1VSkhSNVRzdWhFZWsrRENzZjl4OExwRmxuc1BIZUtBMzNyQlJ3MmxQUm1zSHgxaHd6NWtWd3VVMnIxazVOUU90QkxaUlpCUVE1NExDeEQ5YXpDYXNORjhiOXVzRGJ2MVdlVHZDVkdpZTByUnhsUDVTZTJZVDVqenpwa1RMWTN4QTFSdnVzbWJla3FIQk9OR1o0SHdkKysrOXhGWnhlMGliaUcwMjg1RVpjbXZWUzNINFpKSk5VdEpXWTV2akJYZFNKUWo4YTVBVnpYNWo1S2RvSnpCVkhpZFROQTBlbEk0SzNzQkFvd2FKKzBKSWcrWFJCQ2ZTRUtZNnVwbnJtQWpubm9hZkhKYzJOMlZOQWx2ZzBEMHdyekQ4ajRPN2d3ZTArT0dxcDFmeVNwbVU3eCtiQU9nM0JXaTNBcXlGRzlYbkc0QXRxSVJIQklTbnJSY2pMTlI1ZWZvR1NRNWsxYlJBT1YxenJxRTNFM2s2c3pSR1BrOEdEN1dCS1h0WURLZ0tyTmtGcklNcklGUHN5Q3RlNFNNb1hxbWpTaURkaEhsS3IzcGJuNllyRTFRMWlSZldEOXJKZVZ1WmFKajNTemlvcFdIKzFXL0Z3dlBBUitiQSttaFBkNVprSEUyUUVNZElFL2pBTUtHT3FKVkFyRVhBUzBqdXBNSXZBQUl0RlJmSFVQNDF1bEJNNWF6L0lBaEZSdFJtbFpLRzN3cXhTb0FPRXFDS3NHL2IvTnVUNTBOT015VjVrTWxVU2N2anA2YUZ5ZmZGYmRZYVpDcCtvR2kwZ2IrYXl3WDJSVzh0WU5EUy9mK0hhSHFRZ2toWi8wRW1DdStKOCtkZ1l1V201L1RER25kM3BEeHRjK3RSeEZTYU9jdW1qQVRKWi9PeGdHcUdrYjBSREJwR0V1dnQ0WFFpdFRjMmxEUHZmSjI2Ynpmb2poZjNSbEZYSjNKY0hiVW5JdE5wa0V2aFNlVk1Lc2hDaGdKRWd2amtiV1dDSHpKcXBGVGdLVncrbFpHY242a3c5UWFWd29ScFcvb3hWaUoxNThva3RySkxyNTdHSHhyNDFmaUJydXRjdldNelhRa1A5dEZNMEVTU3V6OHNnUzBFeUc4UkQwa0FhNnBVYkJYeVNpQmVTdFVRd1pQZmZVcXR4NFVHN3ExOVJhZlRRTjRkbHBjV3pyOHRCTy9YNFhZWlhGVjRRTjk0WEE4SnlOeVpDMHBpSWdHOEczZzU4K0E2cEtZRS9nTk8zb202SEd4VDdRQUFBQUJKUlU1RXJrSmdnZz09IiB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwtMSwwLDEpIiBoZWlnaHQ9IjEiIHdpZHRoPSIxIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJub25lIi8+CiAgICA8L2c+CiAgIDwvZz4KICA8L2c+CiAgPHBhdGggaWQ9InBhdGg1NTIzIiBkPSJtMzEwLjE4IDIwNC41M2gtOS4xOGMtMjEuNzM5IDAtMzkuNDI0IDE3LjY4NS0zOS40MjQgMzkuNDIydjUyMy4wN2MtMjcuMzQgMTUuNjQ0LTQ0LjUwOSA0NC43NzUtNDQuNTA5IDc2LjgyNiAwIDQ4LjgxMSAzOS43MTEgODguNTIyIDg4LjUyMyA4OC41MjIgNDguODExIDAgODguNTIxLTM5LjcxMiA4OC41MjEtODguNTIyIDAtMzIuMDUyLTE3LjE2OC02MS4xODItNDQuNTA5LTc2LjgyNnYtNTIzLjA3YzAtMjEuNzM4LTE3LjY4NS0zOS40MjItMzkuNDIyLTM5LjQyMm0wIDE1LjVjMTMuMTU4IDAgMjMuOTI0IDEwLjc2NSAyMy45MjQgMjMuOTIydjUzMi42NWMyNi4xNiAxMS4xMDYgNDQuNTA5IDM3LjAzMSA0NC41MDkgNjcuMjQyIDAgNDAuMzMtMzIuNjk0IDczLjAyMi03My4wMjIgNzMuMDIyLTQwLjMzIDAtNzMuMDIzLTMyLjY5Mi03My4wMjMtNzMuMDIyIDAtMzAuMjExIDE4LjM0OS01Ni4xMzYgNDQuNTA5LTY3LjI0MnYtNTMyLjY1YzAtMTMuMTU4IDEwLjc2NS0yMy45MjIgMjMuOTI0LTIzLjkyMmg5LjE4IiBmaWxsPSIjMTAwZjBkIi8+CiAgPGcgaWQ9Imc1NTI1IiB0cmFuc2Zvcm09Im1hdHJpeCgwLjEyNSAwIDAgLTAuMTI1IC0yNjguOTggOTUxLjcpIj4KICAgPGcgaWQ9Imc1NTI3IiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1NTI5LTcpIj4KICAgIDxwYXRoIGlkPSJwYXRoNTU0MyIgZD0ibTQ2MzMuMyA1OTQxLjloLTczLjQ0Yy0xNTQuMzcgMC0yNzkuOTYtMTI1LjU5LTI3OS45Ni0yNzkuOTZ2LTQyMDUuNWMtMjE4LjExLTExNi4yMi0zNTYuMDctMzQzLjIyLTM1Ni4wNy01OTMuNyAwLTM3MC45NiAzMDEuNzktNjcyLjc1IDY3Mi43NS02NzIuNzUgMzcwLjk1IDAgNjcyLjc0IDMwMS43OSA2NzIuNzQgNjcyLjc1IDAgMjUwLjQ4LTEzNy45NSA0NzcuNDgtMzU2LjA3IDU5My43djQyMDUuNWMwIDE1NC4zNy0xMjUuNTkgMjc5Ljk2LTI3OS45NSAyNzkuOTZtMC04OC41OGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtNDI2MS4yYzIwOS4yOC04OC44NSAzNTYuMDctMjk2LjI1IDM1Ni4wNy01MzcuOTQgMC0zMjIuNjMtMjYxLjU1LTU4NC4xOC01ODQuMTgtNTg0LjE4LTMyMi42NCAwLTU4NC4xOCAyNjEuNTUtNTg0LjE4IDU4NC4xOCAwIDI0MS42OSAxNDYuNzggNDQ5LjA5IDM1Ni4wNyA1MzcuOTR2NDI2MS4yYzAgMTA1LjI2IDg2LjEyIDE5MS4zOCAxOTEuMzkgMTkxLjM4aDczLjQ0IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTUzMykiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzU1NDUiIHRyYW5zZm9ybT0ibWF0cml4KDAuMTI1IDAgMCAtMC4xMjUgLTI2OC45OCA5NTEuNykiPgogICA8ZyBpZD0iZzU1NDciIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDU1NDktNikiPgogICAgPHBhdGggaWQ9InBhdGg1NTYxIiBkPSJtNDQxOC41IDQ1MDF2MTE4OC42YzAgMzAuNDYgMjQuNjkgNTUuMTYgNTUuMTYgNTUuMTYgMzAuNDYgMCA1NS4xNi0yNC43IDU1LjE2LTU1LjE2di0xMTI4LjRjLTQzLjMzLTcuMTQtODEuODktMjguOTYtMTEwLjMyLTYwLjE0IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTU1MykiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzU1NjMiIHRyYW5zZm9ybT0ibWF0cml4KDAuMTI1IDAgMCAtMC4xMjUgLTI2OC45OCA5NTEuNykiPgogICA8ZyBpZD0iZzU1NjUiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDU1NjctNykiPgogICAgPHBhdGggaWQ9InBhdGg1NTc3IiBkPSJtNDQ3My43IDE1MzEuN2MtMzAuNDcgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnYyOTE0LjFjMjguNDMgMzEuMTggNjYuOTkgNTMgMTEwLjMyIDYwLjE0di0yOTc0LjJjMC0zMC40Ny0yNC43LTU1LjE2LTU1LjE2LTU1LjE2IiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTU3MSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzYxMTciIHRyYW5zZm9ybT0ibWF0cml4KDAuMTI1IDAgMCAtMC4xMjUgLTI2OC45OCA5NTEuNykiPgogICA8ZyBpZD0iZzYxMTkiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDYxMjEtMykiPgogICAgPGcgaWQ9Imc2MTI1IiB0cmFuc2Zvcm09Im1hdHJpeCg5MTUuMiwwLDAsMjUzLjIsNDEzOS45LDYyLjkpIj4KICAgICA8aW1hZ2UgaWQ9ImltYWdlNjEyNyIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFYMEFBQUJwQ0FZQUFBRFdTZk5vQUFBQUJITkNTVlFJQ0FnSWZBaGtpQUFBSUFCSlJFRlVlSnp0blh2d1pWbFYzNy83M1BsWGNZYUJZUmdITWp3RVpCeFVZa0lrRXNnWUpKQ0NDQVpJYXBKTVVMUWdFZ0ppRWFPeEtLTXhGZ2tsWlJKS3NUREdKQll4OFZWQ0JKSm9JR3BDb0ZRRU04K2VtVzU2K2pIZDA5UE1qTk5POHpzN2YreTc5MWw3N2JYMjR6enU3L2ZyUHF2cVY3OTc5bDVydis2OW43WE9PdnVjYTZ5MUZsdTU4Z2xmZ1NYbHpydVB3QmdEQURDbWd6RUd4aGgwMi8rbUc4cThoTEx0YTJjYkgzZEUvMUtYa3krOXdiM1lUam1hdXJIa05UT2t4eDJyeXRreDNXTGJ1VExlVjZYTkpTR1phVnVyVEZ5eXFTbnI4L1ZSZnpuZDZQVmdZNW5PVjN6azg4Tmh6eHYwTnVuQWU2Rk0wcFBMK2dvZG16OW1ZN1hnK25xZnR0ZmI1dTJBMnBHcW5yVHhEVi8vQWl3cDU4NC9IRjZidm85SGY5VlhmZVdpbmQ5MTVCN1g4UXI5eVhMeXBUZEVvRlFkQUJBRE5RUDJCTXJTMG5KSG9DMS9hN25VdjZwWXA3YTRWQTVYQlh1dWpWcm90MENlNi9QMlJvQStNbCtoM3dUOXBXRVBBQTgrOUtYb09FQ2Z2Z0ZYWC9tRXhRZHk5ejMzcmRDZlViSU9BSmpuTEVDekYreFV2Vng1cVk2cTFUcUhCU1VMOGtoeFpIME40QVc5TE9TNWZnSjlIZlJmK2RFdmtEcDUwQ3YwNjZIL2dwdHVTc1k4cDV3NWR6NDY5cHcwWDk3YmkwYm9GK0pKVDd4eTBRRUJ3SkY3ajY3UVgwQzRBd0FhemdLazR6a2RnYVpiVXpkRmR3bXA5VHM1dlZ4ZEJlQ0JSc2p6WTJhYkEzMnN0MEpmYTdzRS9hVmhEd0FQbkQwSFlHQWtGZlBsdlQycnZsRUFucnd3L08rNTc5Z0svUVdGWHdNQUNtY0JURmM4Rm1CZTdRZ1UreXE3cWJwelNzdUpSaXZZTXpaRndFdTJ0ZEg4VnZjSi8vV1B0M1g2d0Zmb2o0UCtUViszUFBCUG56Mm5uMlIzSGN6Rml4ZkRDSlAzZnp1UmE2NithcUhoRFhMMDJQRVYrZ3VMNUFDQUdad0FVTzhJY20xazJoclZ6bExTQW53TjZvVzIxTlJSS1lxWHlnclJ2SWQ4T29ZVitscWZyZERmQmV3QjROU1pCeU4rQXNMWDkzRUNmVUIvTTNjQi9tUEhUNnpRMzZITTVnUzBzZ3k4WjkzRjArSWtwa29KNGx3S0RxSWE3bHBiWWxrRzhsc2JEZlN4M1FwOXJjOFc2TjkwNDQzSjJPYVdVMmNlVk9zU0ovRDR4WXMyOStZQ3crSTg1Y2xYVHg5ZGhSdy9jU3E4WHFHL3ZQZ3YzK21YUGNNVnREb0J3YVpZWGdCMTg0WGFYWDBFR29lVnZkaWJjeUFqQWUvNlRPMXFJSisyczBKZjY3TUcrcnVBUFFDY1BIMEdnSnkvNTJLTWdmblR4eDhYMzcxMFFRYTVkb2Z3WDZHL3ZFaGZQcURCQ1FDeUl4QnNpK1ZlUmtUdlMrL29xZDZ0NDJWa2FrZXRxd1E4QUh6VngyNGJpaFFRbDJTRnZ0NW5DZnE3QXY0SkQzeWhUbk1DRWZSem9PY0w5dFJybmpSdWxJMXkvNmtIVnVndkxCcjB1UVFuQUlpZnNpWm5vTFRSVk05bHlUVFBHRzZXbGxXclY1eUwrRGJaR1BDaTNRcDkrWGdCNkgvZGpjOVB4ckdFM0gvcWdhUXNTb3Z6T3VJQXpJVUxGK0tjUG4xZFdLVHJycjJtY2FqajVlVHBNeXYwRjVKYTZFc3l5aEdFeWtLL1k5N1dKVDRLWTVhbnhxWUY3dHMyci96NDdZSitYWHEyVlZibzYzMUswTjhWOEduNjJ3dVA2ck1PNE1LRkMyRnU0c0lKYjJKMEttTXRyci91MnZvUlR4Qi9zV0tGL3J3eUJmcWFhS2toS3NXM2JVcTZacTZQeE5naEZGSkIyU1hmMWwyMUJYenhoR0dGL3I1Qy84Ym5mMjNTOTFMeXhmdFBodGY4QW0wb0x6Z0E4eWVQUFphc1ZnM291ZTdUcnIrdWN0alR4ZTlEWGFFL2p5d0JmVTFLWndaY0pyMjFVM1A4clRsOGF0cVEycmxLaU40VlZhV3ZGZnI3QmYxZEFmL29zZVBoZFpTcUNjOHlxM2NBQWZwalFFL0wvQnQ0dzlPdnI1ekdkUEczR2EvUW55YTdoSDVKSXFjQVRJN1l4M3cwSmk4SHMzL2lmN3RUVnF1RThRcjlnd2Y5NXovdmVVbC9TNG0vZ2RVTDM5TE95MHNPd0R6NjZLTnhUbjhFNkhuNU0vN00wMnJtTW91Y09YZCtoZjVFT1VqUWJ3SFVBemMvcTc3aG1vOUk1VEpvRUcrVkZmcUhEL3E3aEwxL1Bwa1gvM3FxQXpDUFB2cW9uUVAwL3JWL001NzFqQnVxSmphWDhDZkpyVkl2aHhYNlZlMU50Vjl3YlZib0h4N29mKzF6bjVPMHY2VFFweEY3R2VNQUpCdnp5Q09QV0FuMHdMQkl0YUQzWlZUbmE1NzF6SW9wemljci9OdGxoWDdHZm9WK1U5MmxDUDFkQS8rT3UrNEdnUFRwQklJRG9QV1NBeENqLzRjZmZ0aEtvQWVHTjBVOUU3RHhtUUQvRDdpRmZlNXp2cVptcnJQS0N2OTZXYUdmc1YraDMxUjNLVUgvZWMvWkxleHZ1LzBPQUhxVVBzWUJpTkgvbDg2ZmR4ZHlNMUg5R05EVEJiUjI5L2t3WUFWL3JhelF6OWl2MEcrcXV4U2d2MnZZQThBZjMzWjd0T25Bd0loUnVuOHRPWURhOUk4NS85QkRGc2hIOWJuMFRkRHgrWDk0RzBTMnU5N2lSR1dGZjE1VzZHZnNWK2czMVIxbTZPOUhSdUtQUHYrRjdXUGxZNGpYT0lEMGYyWDBmKzdjdVNqU255dXFIK3FaazRERkxoODFTbVdGdnl3cjlEUDJLL1NiNmc0ajlQY0Q5Z0R3dVQvNkhHQTZHTEFvdm9zaDdybnQ5VnJTUCtLRjNBY2ZmSkJCZjF4VVh3UDZvWjNkL1Q2a0pDdjhZMW1objdGZm9kOVVkOWlndngvQS8vMC8rRU4wQWV6YlFnOXBEQkg2Mk9pL0NQK3paODl1b1Y4SCs5cW9QdWlGc3dWMzJQYzJPWU40NFRkK283ZzRTOHNLZnljcjlEUDJLL1NiNmc0TDlKL3o3SVo3UEdhU3ozejJzeUtVdTg2d256TnRpLzZiVXo5bnpqd1FWb0xDdmphRjQzUDFFZXlGcUY1ektyMjNCL0RudituUGlvdTF0Rnp1OEYraG43RmZvZDlVZDlDaHZ4K3dCNEJQLzkvUGhOY084aW1VcGVoZmc3KzNhVTM5R05NNTZOZkFYcm93cTZWd3BLamV0MjJ0RGFCM2RxNU5heTFzYi9ITmYrRkYrc290TEpjci9GZm9aK3hYNkRmVkhWVG83L3ArSVMrLyszdi9HNlpqME4zV0RaQTNTVVFlUmY4VFVqOFMvTTNwMDZlaW5ENlF3cjQyWDU5TDRWRFllMnNLZXkvOXR1eGJYdnhpWlJtWGw4c04vaXYwTS9ZcjlKdnFEaHIwOXd2MkFQQ3AzL2tkR0dQUWVXaEhrTytpSjRQNDZMOGw5VFBjZ0VVY1NnMzhUNTA2T2FSM0prVDJBZWdFOWxJS0o2cmZsbnZRZXlWcSs1ZGU4aTNTZXU1RUxoZjRyOURQMksvUWI2bzdDTkIvOWpPZndkVjNLdi96azUrS0FXNFF3UjlBaVA3M0EvN20xS21UZGduWWF5a2MzeWFRaHoySXpjdGUrbEo1ZFhja2x6cjhWK2huN0Zmb045WHRKL1QzRy9hLzlkdS9EV0NJNVAzcjhMOEFmd0JSNm1jTS9HdHkvdWJFaWVQa1F1NzRuSDB1c2xkaEh4d012YVlRdDJmN1BudzQvc3EzZnF1MDFqdVRTeFgrSy9RejlpdjBtK3IyQS9xN2ZyZ2psNDkvNGhOYm1MT0ltc0NjbHUwMy9NMkpFOGZ0bE4wNGM4S2U1djg5N0tPeTdkOWZmY1VyOHUvQ0R1UlNjZ0FyOURQMksvU2I2bllKL2YyR1BRRDg1c2MrQm9CQzNneDN2bmJkSlBnM3AzMHF0M3FhKys4L2JxWG92bVhyNVpTY3ZSYlpTN0QzWS9UbHIzclZLek52eDI3a1VvRC9DdjJNL1FyOXBycWxvZi9NRzU2dWptbVg4cEdQZkpSQk5kNkpzeXY0bDdaNml2bis0OGVQMmJuejloVDJYbjlPMkhzbllxM0ZhMTc5NnVJYnRBczV6UEJmb1oreFg2SGZWTGNVOUE4SzdIL3QxMzg5Z201bnVpcjRhMm1mWE01ZjIrMlQyK2Rmay9JeFh6eDIxTTZWeXBHMlh1WXUwUEtjUFFWOXBMOTFTaFQyWFArMTMvN3R4VGRzVjNMWUhNQUsvWXo5Q3YybXVybWh2OHRmNGN2SkwvL0tyd0NJWVY2Q1AzY0FIdjV4ZWV3TVBOazUvR3Z5L2RvZS95VHFQM2IwUGd1MFIvZityWkdpKzF3cUo0N2VoK2krQnZaMEhJUFRzSVBUZ01YcnYrTTdLdDdDM2NoaGdmOEsvWXo5Q3YybXVqbWdmMUJBRHdBZi9xVmZjbUNGaDNZSzh3SHFCT2hkcXVPUEtmeW5wbnlxb241K2M5ZXhvL2RaQ2ZpbG02dm1pTzV6cVJ3UGUxN1A5WHByWVpIVy9jMDN2S0gyZmQySkhHUUhzRUkvWTc5Q3Y2bHVDdlJ2ZVByMURhTmJYbjd4d3g5T29jM2dMMGYwY2RSUDYycFRQalZSLzloY3Z6bDYzNUd3OGxvNloycnVma3AwTDZaekdPeHBIYXpGM3Q1ZyszZis5aTMxNy9LTzVLQTVnQlg2R2ZzVitrMTFyZEEvYUtBSGdGLzQ5LzhCeGhoc050c29tNmQwUUMvU3B1bWVVc3BuYXRSZnl2VVhkL2g0NkplQVgwem4yUGlzSU5LdHlOM1hSUGM4bFJPMXNZVzladnYzYnYyN2pXLzk4bkpRNEw5Q1AyTy9RcitwcmdiNlQzL2FWNDhhejlMeWIzLyszNG1wbTgybWk4QWY2b1dvbjl1MlJQMVNmbjVzdWljSGZuUGZ2UTc2SmVCbnQySTJwSE9TeUJ4MUYycWxWSTdYNGRFOXI2ZDl2L2s3MzlUNldkaUo3S2NEV0tHZnNWK2gzMVNuOWZXMDY2OGJOWVpkeU05KzZPZElkSzVIN2h6K3RWRi9LZGZma3U2aFp4RnhuL1Y1Zm5QZnZVZHNEZkFwb0FFZCtMbXRtQzNwbkFqOFd1NStxeU1CbjhKZUdzOWJ2dWU3UjM5SWxwWmRPNEFWK2huN0ZmcE5kYlN2Z3d4NkFQanBuL21nQUZxVGdKL1dTK0FIOUZ4L2Jib25janBDdXFjMXo2ODl3YlByRE13OVIrNjJCeEg0OFJtRkRId1ArNmhQQnZ6NGpNS1BwdytPNUcxLy82MWpQek03a1YwNGdCWDZHZnNWK2sxMTExOTM3YWkrZGlVLzlhLy9UWUF6c0FWaEFsb1N2UXNBcCtDUGRETGc5M3E3QnI4VThac2pkOSs1emVsUFMra3NBZnhTL2w0OEt4Q0FIMTlQR0lEdjV0dWp0ejNlK2ZhM2ovNGc3VXFXY2dBcjlEUDJLL1NMZFFjZDlBRHd2dmUvSDUzcDBKRjk4Z09nWmZBSG5RejRBUWJ1RWJ0N3hvSy9KZFVUNWZpUDNIMm41Y0FINGloL2FnNy9JQUNmUG1xQ0F6KzAwMXU4Ni92ZU9mcUR0V3VaeXdtczBNL1lyOUFYNWF1ZitwUlJiZTVhL3NXL2ZCOUF3T2NCNmVHZkF6OUFkdGpNQVA2V2lELzNDQWNPL3RhTHUrYnV1KzRJa1g1dUgzN053OUkwNEh0OVlMNlV6aExBMzdOQTc3OGcxdUlIM3YzOUV6NXV1NVVwRG1DRmZzWitoVDZBd3dONUx6LytFKzhGakVIWGRkZ1lST0FIRUVYOWM0Qy9KZFZUbStPdnZZTlhTdk00SFJuODV1Njc3ckFsNEFQNXRJNmNScEZ5NmpMd0kvM01SZHVXSFA1VTRGdFk5TnYrL3NrUC9zRFV6K0RPcGNVSnJORFAyRi9HMEwvdTJtdEcyZTJuL05NZiszRUh3YzMyOFFNSytGc2kvdHBVVDVMS0tZQi96SGJPMm4zOFVyUWZiTzY2ODNZN05xM1Rrc2R2MlljdkFSOG83OUtoNDNEamR0QXZBUi9BQUgwR2ZHdXRXNU8reDN0KytJZW1mU0wzVVhKT1lJVit4djR5Z3Y1aGhEd0F2T2RIZnRTQkVmRnVsUnJ3ZDJUM1RnbjhuV0ZRejRDZlExK3lLYVY1NG5ITm1PYWgwTmVBRDZBNnlwK1MxbW5KNDFQZyszNmtLRjhEZmhnYmpmSXp3SGRyTWJUell6L3lubW1mMUgwVzZnUlc2R2ZzTDJIb0gxYkllL25CSDM0UGk5Umo4QWZvQTBYd1UrZzc5VHo0cFhSTkNmeHpwSGxvNm9icTFrVDd3QmI4ZDl4K1cvakU3Q0xLMTlJelM2UjFvdXNEcGJRT0FYN1VEeEROcmU4SCs3N3Y4UlAvN0VlbmZYSVBpSnc1ZDM2L2h3QmdoYjZvVjZxdmdQNWhCN3lYZC8vakh3cVFObHVRQXl3aWhnQitCbjFRblFYeSt4VDZBSnJTUEVtYWFPNW8zME9mQWg5QUZmUWw0THU2dWx3K0JiN3JLdzk5RGZoUm54VlJmbXRhaDdaQisrcjczdDNKdkxWNzMzdi8rZFRQOUlHUy9YQUVLL1FGdlZJOUcrTlRyM25TeUJFZFhIbm45LytqQVBDdU13bjRKZWhqKzc4MnplUGJBRkNWNXBrcjJ1OG93Q3VqL1NtNS9TdWtCUllmazF3cEhQaTFvajB0czZsUGR2RzJKRnFVWDlNWEJ6NEF2T05kNzBiZjkzai8rOTViUGZhRExGZGYrWVNrN0tDY0Vhemk1Tm9uWDczZlExaE0zdmFPZDZFajBid21mZCtqNnpyMEFMcStCN29Pc0RhQU9DZldXdlRvZ1I1REc1VjJYR3I2azJ4NzlGR0VYbWZiUitDdmxiNjNNdlRUbmpBSzVJQ2MycWszanFQODFuNTVoRjdYWlJybDE5cjFXLzN2L1lmZkY5SkFIL2lwbjJ3YTkwRVh5UkVBcXpOWVdpNWx1SE41Nno5NFJ4YjA3aHBrSDZWMmFxWGZPb1ZOYjJHN2NhQ3VkUXpCRmhhOUJib2VRRU9mdEcvdjBFekJBVXEyUUErUWFMOE8ra2xESXdCT0JyRWZ0dnhoYnEzOWVvaTN5bHUrOSszWTI5cCs4QVAvcXRuK3NJam1ESURWSWRUS1V5NGpzSFA1N3JlK0RadHV1Tm1KU29qazkvcjQ0bXlsQkRzTHNmMnNyYldqWUFzTWpxSVY4clBaOXpiYUR1cGxGUFQ1b1BiRGR0S09rMzUveHYyZDMvTlcyTjdpUXgvOHdPZzJEcVBrSElLWHk4RXhYSFAxVmZzOWhBTWxiM3J6VzJDNmZQcW10N1lwcXFZeUhicmpVaWkrNzdIOVRyWHZiVDVkTkFuNmw2dTBwSXNrZWRPYjN4TE9ISDcrUXo4ejA2Z090OVE0Qmk0UG5EMjN3RWpLc3NKN3ZOeHk2M2RoUSs0eVhXWDNNZ242eHBoRmR6Y2NWT202YmpMNHZkeHk2M2VGOU5Fdi9zTFB6ZExtNVNKUGV1S1YrejJFVlNya2piZmNHbTJ2M0pXc2prV1cwZENmQ3Z3cDlzWVlkQmgzVTVFeEJ0WVpqKzU3N0xqZDdvRGgvZ0V1Yjd6bFZ2UjdlN0RXWXEvdjhjdi82VCtPNm1lVlZmWmIvc1liYjlsdWw5eE1BbjdON3AyY1RBRy8zN0k1dHQ5cGZZKzNMZTBFR2dWOVk3cnFiWkd4M1RDUlZuQk9zZTI2RG01WFZ2dkYyTTRZZCtWOEc5M1gyZ2Q5QUp1dUN4ZHphOFFZZzAzWDRiV3YvMXVBN2JIWFcvemFmL2x3MDdoWFdXWFg4cHJYdlFHYnpnQUtkUHpuZW93VG9QdnRPK0hpWkU3NFB2MVc0VGRvTmRtU0c3UkcyVTV3SHZUaGExVHFvRzhRQnQ0TTY4NjRQYkRvd2lNZHF1eU1jY0RjZE5FZHVYVzJIZEQxNk56MjIzcFFiN3Bocjc2MVFOZFYzVURqN1p4ejZhdjc5RitDUGI4N29lL1JiVGJvOS9ZUzNWZS85dlVocGZRYnYvcWZxK2F6eWlwTHlxdGU4N3JvNWlnUC9BRDJ6U1pyYnpwcUd6K3RzaVRTelZtMUlqMTFzMWI0elZtMUl2Mldib3Z3SjI2MkNIOFVRd0o5WXd6UXVlMCtuZW1hb21NK29HcTd6amhBVzhDUGJsU2ZYWWV1WW0rOVQvRnNlZ2YydnU5aHJOdFBteE9heTNjUlE0Kyt6OXNGNTBYaGp0NDVRY0doK0M5THY3ZTN0UUUyWFkrOWZ1ai9WYTk1WFhRUHdXLyt4cTlteDczS0tuUEpLLzdhWDQ4QUZBRmZrRmlIMkdTaWRYcDNiSGhkc1ZWVGV3eERTYlRITU5RSWpjYnA4L1ZiN09odjZOYmJ0dCtqQUd6dnlPMDZnNzYzYmprYjBqYXUwOTYzVkFWYjcwUzRsQUR2by8ya1BlVHorc0dab0Q3RkkwWDdYU1o2ZCtzblIvdlNCVi8vb2FmZzU5RSsxZFhBVDlmODVhOTh0UnR1MzZQdmUvejNqMzlVbmQ4cXE3VEl6UzkvNVJiU0Rvd2MrSkd3S0o4RG40c1U1ZlBISWFRMitpTVlOTWs5Z2lFbnVaOVJ6SW4yQ0lhaVhlYVh0RXI5U1k5ZzBLU1kzZ25SUG14MWlvZm4zMzJLSjJjblJmdkE5cmtqaWwyNG9Cc04yRU5hZG1EVThkUkUreVpxTTQzMkthUno0S2ZSZnJETmdOOUgrelhneC9ZTHRiM2hEemUvL0pYaHNSUjdmWTlQL3RZbjFIVmZaUlVxTDd2NTI2STBRZzc0WGtkSzYwaHBGeHJsYThDbndxTjhEbnd1cFlldFNaSjcySm9tSE1xMVViNWtWL1A0aGR4emQ4cjlwYy9VdjhJZjBHaC9tNFJYZDVrTWpRclJQblM0TzBpM1Jmczh0MSt5Y2VtcEFmdzB0eTlGMGhMNGFiUWZidkRJZ0orbmVjSUhPQU4rYTIwVitBRmtVejNvT215d2ZYaVhmM1lJaHR2TnIrZzZ2T3ptYjNOOWJ2djlYNS84SCtKYXIzSjV5Vjk4eVY4TzhEVGRGdHBSQ21iNG9aRnNTcWNpd3E4RnZwYldvY0QzVXYxWTVhM1VQR2lOU3VsQmE1Sm9VWDdSWmtTVUgzUWFvbnhqVFA2WHM0QzJwMjA2L2VVZXNRd3M5RU1xVG5IVWMvV2xCN0FCQ0xZOVdZK3crOGZQcFI5MGJEL1UwNTArdm94dTVmUTdldno2aFhGcyt3cFBBOTJtZTJqVTc4djkrOUR2dWJPNC8vTzduOHArd0ZZNS9QS2liMzVKeUhFUGovT05ZUS9JMFQzQTgvTnhkQTlBQkg0RTlBendReHNWd09kcEhTMlBuMHZydFA1SSt0Z2ZVUEh6MXFKOERuemVmdkdSeW02aUNmU2xLRCswSWYwd09nWC9sQjlUOGZvNThGdEp0L0JzZmQ3dUxzSHY1cUEvWDM5WVE2dUNmMWhISzRMZjEwZDFXL0RUT2c5LzNpNkZmMWlEU3ZnSDNiN0haejc5ZTFqbGNNbzN2ZWpGQUxZQWh3TmgvQWpmQWZhK2pNTWVRSE4wNzl2aUYyM2pkSkNjdzQraS9jeGpsSU5PS2NLZkVmaEFDbnNOK0FDcWZ5clJMYVhjL2x5UFUzYWZBd1g2QUlyZzk5QUg2c0RQb1E3a242OGYycDN4UjFYYzJQUGdEK1BMZ0QvMEJ4bjh2cDQvZHBsQ1dZcjY5d2l3cGFnL3FpTlJQNEJKOFBmMkZQNEFnZ09nWnd4dTNYcjgvbWMvalZVT2puekRDLzljL0lYZlF0dUR6OE4rQUV3NXN2ZDZQQXJYdG1TT2llNUR2d1Q0SE5xbGk3YTVITDV2ZjQ1bjV3TjF3QTk2TXo4L2Y2Z3YvMW9XMVpXaWZBQU8rZ0I5VHZ5UUp5K0JQNEg1anNGUHg2MkJmMmhMLzNFVk9pOEovRzR0OUI5WThYMjBwbnNBSlBDUHdGMlo4Z0hxNEI5c1Nkb0gyM25zRWNkQW8vOHdkOEVCUk9NSDhMay8rQ3hXV1VadSt2b1hSckRzekxEdDBRTWJRRGFxOTdveGxDdlNPRTZ4Q3ZaK2ZBbnNuVUZ6ZE0vN2FmMmhsTEFHQXZCcmZ4cVJ0akZIaEQrOEwzbmd4M1VUZnhmWERITTE5eHk1Mi9LZlBoejdRK25BUE9Dblk1bVM0NC9hVWNEdmRjYjhzbFpZbjRhb1ArNnZuUEx4NjFTSy9IMVpMdWNQTWxZcCt2ZjFrZ01BSUthQXd2Z0VKMEFkeFJjKy80ZFlSWmZuMy9nQ0FBemdEUEt1ZmdDQ0ZORjdPd24wd1liMHdTTktuck1Ia0tSeFlqMGQ5dDZXdzk3M0srWHVhZHVTelpoMGpyUE43OUtwdVdpYkEzNTRIdzR3OEYwYkhjeDk5eDZ4ZmU5QUNDd2Y4ZE0rQUJuOFZFY0NmOVNHbFg5TzBldG92NkVieGlta2U2TDJHNkordHk0eC9Da0FlY29ubEJmZzcvVW8vQUVrT1g4QThRVmZ0OEFoK2cvdDJEanRWWElBL25Wb2c1MEZET01ibkVEUW8zTkM3QWlpNDIzOWJmL3Y4N2pVNUxuUHV6RUNJNEFJM0FDU2VnM3l3QkROZTcwQXBZbWdCOUlVRG9CaXp0NlBzeFRaKzc1clV6bStyNmJvM25XaVJ2ZGhUVWY4Qmk1US9qbEUzNFlFZkFwN1NXOU1TbWVZVHgzd3U4N0FITDN2aUdQYlJQQlRPMEFHUDlYUndDL3BVUEM3c2NqcEh0RmhFUEJIZFN6cWQvT0o0UitkeFFpNWZnQ1Q0ZS9IVkVyN1VEM3RnaSsxazZKL0FLSURBT0l6Z0hETVVrVE9YbllDWVMwZ3c1MXZDT0N3bDJ4b21hWkx4WmZkZGVkdFNkMVllZGF6bnd0Z0FLNFhma3kvWEJ6c1VSbURPNEFrVlJQcEs1RzgxOTJ3dm1JUXQ0TSt0S0ZFOWJRUDB3bjFGSjRNdGpXd2ovUUk3SDJiSFBaZVg4cmRwMjNWUmZlMEx5bTZqK3FWYlpsVGdEKzBzUUR3RFJ6MEFSZkVTdUFIeWhkM3ZmNlU3Wnl1WGs3M1JNQVcwajFELzIxUmZ6eW1PT3AzODY1UCtkQTF5OEUvaktXUTg2ZDZZWTJzbnZyeCtsVU93QmttS2FCaGplT3pnTEJPRE5UOFRJRFdVVWZnUGp0NVowQnRJajN5K2FEbFhyUm5JMGxPb1ZXMEczcjRyeWhSUFFuK1FCeTU4ellrd0hzYkNuZ0FhaVR2eXpqay9aamlLRDNPMHdjZEFmUytUeTJxRDJOU29LM2w3S2xlTHJMM1piV3c5K3ZXa3JzUDY5VVkzUS9yck50SzJ6TEZNNHRvVFBXN2RQeThjam44b1h6YjV0R2o5MW9FY01mZ2QyVngxQytCSHhpZjU2ZDlBQ240YVgxTDFDL2FCU0NsS1oraGZEejhnVFR5ZDNPU1V6Z1UvbjR1V3VySEgvczVBaGpsQU53WTB3dkE3blhjVnhnbldaL1FscEtla2M0SWFEMTNCbjdldEMxcTUrZEZSWHB3WHkza3VST2hrTTZKNUFENDNaUlJKQ2RBSFpEQnpzY2lBZDdYYStraERubGZ4Nk41MzI3UXFZam8vZHdrMEZNOUNsQWExZnQ1KzZqZTIwaDJwVFNPSHllRmZkQ3JUT1ZFL1F1Z2JvM3VvN29SMFgxY2wwYjNkSDdEbk9MbzNxMUxDbndLKzYweHJqQXdzS1lEYkE5ai9OMjVybkgzYzJGK29aMURNRnREWXdCckJ2anp4elVZWXdMWXBEdDNZVXdFTk5lZmUyU0RnWUhwVFlBL0ZXTU1yTEV3QWY2SUZzWmFpNDB4RWZ6cFdLeTE0ZEVOb1h6NzM5L0ZhemFBSWZEdnVpN2NOZHZiSHRhNGRqWUFOcWFMNEwvWmJKS2N2LzhnMjc0UGZRTE94bjN3eVRITWNHZnZua0ZIb001dC9UeXgyVGpBZHhZYmJFSUt5TDkzMWxyc2tkZTBmSVBCQ1d3MnhBbHN1dVJNb1BNMm00M29DSUFCMWwzQkdlQ0sxQ0c0OWtpMHorL0Faby9JMEVCZjgyVFVHc245TG1xUzNpRmZQL3FRTHducXRHMzZUQm9KMnJTOEJIaC9UQ041MzRjRytjRW1uN29KWTYwQXZWOERMYXFueDlyMlM5b0dpUE9waWV4ZGUvV3dwK1VVOXJ6Y3czNW92eTI2VC9wdmpPN2pmdVBvUHUySEFkOE16dmNLLzRLQ2Y3TXgyNmkvUzhBUERPa2VzRWMyZEtZRHpCRDEreStDQjc5alZNOGdPMHlVZ3BrK2twbTJTUUZoTm1hQVAyd01CV095OERjKzBxYmx4cmZYQTJhRDN0Z0kvcHZOQnAzdG9zZ2ZHNE9OdFJIOERZeGJRNTc2SWZDbjQ2RlFseHpBQmh2M1RKK1NBM0FkT2NCdjRyVE5SamdMR042YmdoTUFvck1Ccit2N3BzZTF6b0RhMEhyWFIrb1lKQnN2dFkvczVrN0VTODJUR0lFMHNnZlNNd1h1S0hKUUIvTDVmd3BFYXNldkQvQW9QdEt0aEx6cko3MVl1ejFJWU8zSEo0SGU2ZW5iTHIxdE5vV3o3VGQzZ1phT0o1ZkdpZG92d1o3TWw4SitHTE1NK2xtamUwQk41d1RkOEZtSnh4SG16NEJQZ3hMenhXTkhiZlRsZ3cxZmNOdVE3Z2wxRFJkNUI1djZyWjIwclVTL010OHZ0ZUhHWE03NVV6MXR0dytBN0k2Zm9BODU5OC9YUzh2L2h6clNKdDhCQkNCSjg0UzFZc0FXOWZ5eGNsM0EyY1ZuYTVKOWRIR1dSdlpLU2tlN2dDdnBVdGxUeXNlSzlIUklRRDRMNEE2Z0U0RFB5eld3VTcwSTdrNDVHaHNGdkQ5T2JjdVFEM29NMU9HMUJINGxvcWYySFBRQW1xUDZZUXhkTWliNktPU2FIVGtBQmU0NDJOTTJhbUF2alczeDZONFpoM240TVpyang0OVpZQUFyRUlNZlNPRWZBNmt0MSsvdFFmVVR3TTRMLzJqTVZzLzUrN0xjelYxaDNKbThmeGp6ZHNlUHI0ZVZ3YTA1QUc4bk9RRC9QdkVkUU42ZWwzRW5rTlQzUS90MHpXc2RnWmNXaDhCdE9jajVZN09sZEU1dEtxZVU4OWN1Mm5LUllKL3M3TWxFL3hMVWFYbjBYUG9LdU5OMkpNRDdlcHF1OFhVMGt2ZGxHdVJEV1NGSEgvcHJCTDIzMDI2cW91MUtLUnkvemlKb0ZWam4wampEMk11dzkyUGg3ZEIxck4yWjQvcGRJTG9uVHNuY2YvOXhHMkJPd0EvSVViOTd6UzlHVXBzNitFZjlzQXU5VGw4R09oMm5CdjlRUnVEUDlRSUEyVzZmYUY3Q1dZSHJQeC85Ui9PRjdnRDhHa3NPZ1BiankzTm5BYlN0MEtldlo4NlQ3d2dDMmgwQlBaWjA2SzkvY2J0dDQrRWxkUXhhdTFwOVVqZHpwQS9Jb0FmU3lCNFFjdjFFUndLNkswL2hUMzkxcWdYdXJ1bU1Qb0VSMTZtQnZLL24wVHh2UTByZHVIa3BvQWV5dVhwM3JFZjF0Qjl0NjZVL0xsMmc5V3U0SzlnUCtqSHN2VDZIUFgzL2FxUDdVR1U2bUJNbmpwUHZmd3IvZ0tER2xJOXZqOEkvdDcwejBzL0FQOWl6Y2RiYzFldm5remdTcTZkKy9MRzIxMzhZNDN3T2dQYmgxek4zRmtEWGhEc0JJRTBIK1hiQzNEakVGYmhyem9EcTVCeENVaTQ0Qmk5Sm1rYkoyM09ISVVuT1NWQ1JJQzZKK0N0UlFyNWZncm1YSE5ScG1lZ1FhdUR1Q21RN0F0R2tyQUx5dkV5SzVyMHVmK0tsdDVVaWV0ZFdIZWlwWFhWVXYxMlRwTDV3YzFXWVN5R05NK2hWd0g2N1dCejJnODcwVkU3Y1poZDB6Y2tUOTRmWTBnWEE4VmE1Q1A2WmxJOHJteC8rYmh6bGJaNitMSGRucjlmTnBYNkFPUHFYMnREU1AwTmQ2Z0NBSVFVVTljbXZBV3dYbHdJN21TOTBKOEIxQStRTFp3TytMYWxjY3dhaUxqMjdVMkF2MlhOOVhoZDBoTjhQenVuUEpacERrSDZYY2JhV0FBQU8vVWxFUVZRSE5nRjhOdnJYb1E0Z0Fzd1V1SXZsRk1nWUxyNVNIUjdKKy9ZMHlQdDJ6UkRDRm5QMGZteFRRUy9WNTZMNnVDKzlqYkU1ZTZlM0RPeTNTN3R0TDVQS0liQjNYUnVZVTZkTzJyQjFUb0EvQldyUW1RbitRRjNPUDdUWjkzTEVUUjFHSnZVVEhTdlJmK2kvMFFIRWJjY093STlMaXE2bHN3Q2czUW00UHZLcG5Kd2o0RzFIZW9vOUxmUHJCZVNkZ2w4TEtpWFlheGRuYXlGZnU0ZS9OcThQeUU1QXV1aWJjd0FTMExrTkJ6dXQxK0F1MWlrUlBOWFJvbmczMXZHUTk3bzhtdmZsUEVmdis1VlNONkZPZ0xSL3JlWHF3L3lWcUo0ZWF5a2NiaU5mVDVBdjBBN3pIUWQ3WDFjTCsrM2JFZVlkM3NQVHAwOVpHbEVQR2VYNTRlL3N5dkNudGdBWmd3aFZQZlhqajdVYnZjTHhSQWN3OUtPZGlhUVhnZW40Z2ZRc0lQeFhuSUFmS3dlMWRqWkE2NUtkTlkzT0lOS2xEamZuRk1pNGdSVGtIT0R5UlZzZDhuUGNnWnVUbkVPUWZxa29GOTBEekVFSVFPZHR0SUFkaUtOM1NiY1c4RjVYUER0Z3pvZEQzdi9QUmZOT0o3MFlTL3ZsanplV1h0ZUEzclUvTGFvZjVpUTVKWDAzam0rYmpzSFBmZTdJbnVwUzJBZmQwNmRQaFcvU2ZzR2Z0a3NCM1pyNm9YMUowVDl0WHdOM3F3T1ErdlFPUUc2ZmczaThFL0I2ZnVFMVNFdG5CUEVZNnB5QmV4MDdCS20veEVad0RKS2VWQTkrdkpYYXJabTFad05lcEFoZUUzRTdwMkNmamZaNVhRYnFRSnFTY1hveTJPbHI3YXlnR3ZCa2J2emlhMkxYQ0hsdUg2QmFnUDB1UUU5ZlMxRzludyszS1cyOXBHdWV1MEJMbG4weTdJUE5tVE1QMkJTc1pmaTdlcGJ6ZDRWQlY5dnRBNkE1OWNQdGE2Si9ic1BIUE1ZQkpHM2FkQXVvOUhxc0V3ajJKQjJVakNIakNPaDdNOVVaU0xhOFBuYnNxV1BndHR5ZTlzOGxCL0NsSTMyZ0VPMExzT2RiTjB2UnZ4U3BPN3NZM054V0F6c2Rnd1QzeUxZUjhQNC9CWHpVM2dqSVMrVkpOTDhkbTZpanBHN29hdzU2TjZaMFhyRnVmVlRQMTBWTDRiZzFpL3NKYTVXNVFCdmFHZ043Ly9yczJiTldnckkvQmdZWVNEbC9WOC9zMkZaUFlIcjBIOFpqQldqTjZBQ2lkcGdEQ0cwaWJhOTBGaENQVjNjQ2ZyMTdQamZsYkNCWkgvSmZUQTJSTjZYV0dmZ3grZjZIWm1Ud1M1OGhydE96VkkxRmZFekh4YVUyZWgvckNLcjM3Q3RuQmRJZHZnYXhidGZ4NHp6UU5SM3FXRFN3Ui9vWnVBUElBdDRWQzgvN2p5THFqcFdsTzM5eWtPZGxValFmWHJPTHNWUkhha3VMNkNXNzNKMnp2TjNXcUQ2ZGk1Q3ZkNDFVN2NaeDdaZGhIK3pPbmowYnZrRldnVWNNeWo2Q2YyU1hnYi9YbnkzNjN5cW1qaXJ2QUtJNjVnQjhYZWtpc05mWHpnTENmOFVKSkcweEo1RG9DV2NEZE14akhBRlFQak1BNU5STnppblFjWEs3cUMrbFhnTjZEdURjZ1N3cEhOWnhYZmxDcmxTbUFWOENPb0RJZlhCZ0FuSUthQXpjcVg0dDRIbGJpZDBFeVBzNWxhSjUvMXI2MFJLcDM3bEI3M1RybzNyM09qU2lSdldoWFFYMjNNbnp2a0w1Z3c4K1NLQWZROFdYbGVEdjZ1cWpmNi9mRXYySCtvVWNRRHEvL0ZsQThyclJDV2g5UjA1d29pT2dOdHdaU08xbUhZSlREUFB6b3FWdEVwalQxeFhwbkttcG5OcXpBVTIwS0o2TGRsWlFrKzdoTjMxSk1PZXZ0UlFRaHpxUWdwMjIxUUozVno0TzhMeU8vNitGdkIrTDFtWXViWlBYcjB6ZHVNRWs2MWFUdm9ublBTNnFkLy85TU9Tb1BwMm5Fdm1mTzNmT0Frb2twOEEvMGhGU1A5Uld2UERyS2lMOUpSeEFPaS81SWpDZlg4dFpRTHhldWhQUWJFcG5BOXd1NXdqOG5HSzkrSDJRMnFoeUNFNGh2TnlqeFlKam9QTU9ldnlwbVpWUmZnN2ZTOXlCbTVQY2t6ZUJHTnlockNiYTEyRHVsQWU5U3FqVDF4enNjVjA3M0VYN0N0QnJPZm1najNSTUpjZFJHODBQK3NPOGNvQXNSZlNEWGhuMHZ2MldYTDIzMGFKNmJZN1NYSkt4bkgvb0lRdklwK1JTMU96TGw0eit0ZjY0QS9CNmtnTUlZN054RzJCMm9VNDVDeEJ0RlNlZ3JRMGRYL0ZzWUxzUWUxcWVYZmxmNnd4aTNiSkRBUGhPbklySVhuRU92QzAvVnk1U2ZqL1lLM24rcEkySmtUN1FFTzByVCtsTTRPMGFqVzJaRTlsd2t3TFFuWW9PZFZjdlJiN3p3VjM2VHdIdnk2VW8zcmV0WFV6bFphMlFIOXJQUi9QaHZ4bktXbE0zdEMydnR4VG8rVnk5cmFUajF5Q1VmZW44K1NUU2w3NzR1YlNKcURPREF3RDBNd0JmNTJ5SGNkRkllNWhQL1ZsQTZHT2tFK0Q5WloxQm95TUFNTm9aNUhUY1dLUUx0YnBUNFBicERwekdxRjdKeTNPbkVabnNPTXIzVXJyUW00QWJpT0R0aFR1VkpQb24wS25MKyt0UWQvVTZGRXB3bDhxMEZJMzR2eERGKy8vcGVQUjBqV1lyNWVicG12QnlLVzJUam5GZTBOUDVTT2tiVjErZnE1Zld4SzlGMHNiRER6L3NvQy9kUmFsQ1g0L0dSZjBkT2dCbnp4eFQ0MW1Bbjl0WUorRG41T3UwaTZxMWprQ3pwWXRUY2daYVdjN0JTMDZCMnZoMTRuT1c5SGg3Z0E1dDdmbjR0ZEg3SEZFK1VCL3A1M1NsWi9BWHQyNXlaeURBbk92bG9rRmVKb0tnQUcxQWp0N0YvNFVJbnYvbndDMUJYb3ZrMC9yMmFINW9JOTUxQStnWFk0YzZvandSOVBrMUsyemRsZDVYMnU0amp6eVNSUHJ5THhvVm9MQ2tBM0FWa1kyMEM4ai96NldCZkIvRHZNWTVBVDhYS2EwaXJRK2ZXODRSaVA5SE9nTkFkd2lsTXRwZlRpZWRwK3dnQm4xMkZxQkUrZHArZmEzZlhVck9JWEJvQnhzeDJvOGRRTzFqbWZseExkVHA2eGF3aTNaQzlKNXJmd3pnK2YvOGZ2L3hrUGYySFBLOEQ2K1g3THB4RFJaVE45Um1hZERUdHFPeVJ4OTlOSVUrZ1JvdkEzYnJBQktiaHJNQS8zOE9KNkROTVlLOGNITlRMdktXNWppWE04alpiQThBREE0aG5WL2I2MUpVWDB6ditISTErdDgvd05lSUJub2cvbEtITWtFL0IvWEVHUlNBVGwrTFVIY1ZSUnNPZGplZk10enAvN0dBOS8zS080WFNzeVdhcnZGbGMwSGUxM1BJMHpVS3RvVWN2VE9kRG5wQWNmQ1p0Z0VDZlNvSHhRRWs5aVBQQXZqWU5DY1F4cWc1QVdLZ25RM3dPdTB1MTJ3YVJuQjJyYzZBdHRNTGExWUV1WENtd0hWejdVakhVdG1ZdTI5cjZwZVdVdXBIVC9lMHdiNTBYQUs2cGgrVk5ZQmRLcXVCZTg2dUJIajNQd015azQ1Qm5rTWQ1TDF1YlRUUDU5cDZNYlkwVC82NkZ2VDhkU2o3azhjZWM1Rit4Wk1NNTNZQXBmWkhuUVc0eXNpdXhRbTRkbUtBMC83Y1BGTkhvRWJ5MHU2ZUVZNUFtM2N5RnNXKzFTbG85dUs0MlBIZTJLMlpJeDNBV04xYUtRRytScThtc3BmS05ueG5VRU9FRDhSUmFBN3E5SFd1VFBxaDl6RndkM2JqQU8rUCtmV1NtcHo4VUQ4djVLbGRMcHFYK3Q4SjZLbnVZeGN1MkZ4T05pcWY2QURTMS9WbkFjRHVuQUNRWGhpbSt2eHN3TTEzM0JsQjlGOXhCcUp1ZzBQUTdLcGVLenQzM0JTblJmbmNXWGpoVHFQWXpqNUp5Umtrd0I0TWkyMFZqeFdZQXh4SU9zaTExelZnbDlxZUFuZHhySldBOTIzeEVlWjIyUGg2ZHh3MW5HeTFsVkkyM0s0bG1xZHRhTHFsSEwxbUY0MWIyVEJnTGx5NEVIMkZwamdBcmovQXA5MEJsUHJnZTdtbk9BRmczTmtBMWE4OUk5RG1KN1lsckt0MmRwQnJsNDlMU2h0cGJlVmVpOGVsSFR6S0h2d2N5R3NodjVRenFJMzBTN3JpRjFQWXk1K0RPRytuTnZVVFI0M0N4YjNDbGxJTjdIUzhjOENkam8rUHJRUjRyeU5GOFY0bmVRdEdSUEp1eU10Rjg0bE9JVWNmVDBmZkdlWmZtUXNYTGlUZmxBRm9FanhTSjlEaUFJRDljUUpPVDRpNjJZVmhid3VrRjRlbHNaVE9DSHkvVkZxZGdkaG14aUdvZXJrVWpURE9HdWRRYzl4U0pvMkR5MEc2cUp1N2dBdklNQVVVQjlBYTdiTmpLVUt2SFVQdWpDQzMxMThEbTZnekF1N0VMRWlYdmQ2UWorSmRlN3VGdktRYjVqSXhiY1AxRXh0QjEvenA0NCtIYjVBSWRQcDZnYk1BWUJrbjRNWk82NFYyS3M4R3FIMnJJM0J0eG4xWE93TmlYT01RTkwzU0V6SmJIRVBVcjNMMjRLVjBnMVpOUC9zZDVYdVpJOXB2S1UrQVdJaitOZDI2eUYrSE9uM05RYWEyV1FsM3FXMDZxeHpjcVUwdDRIMGI2WmxUZk9IVjIyVmhtam1qa0hURG5HWkkyM0Q5eEtha1M2SFBoVU45MXc0Z2E5L2dCSHgvdFdjRFNWc1Zqb0NQU1JwWHJUT2c0L0FpUnRyTUlmRFhRMWxGQ2llenQ3NHB2Vk1MYVNXYUw5bHJOMjd0bDBnM1gzSFJ2N2hsUjFJNkk4aEYvOXJOVzFRdkY1MW0reXFBM2ZWWkY3a0RiWUNYVWpTdTNUckF1LzkwS3Zrb1ByUTFJWkpQZENaQ251cEtGcmxuUkpuSEwxNjBOVi9VRmdjZzZVdTZOVTRBYUQ4VDhIVTVJRWxuQTA2bnpoRkV0ak03QTlkMlBLK2NRd0R5WndtSnZScHhWMjdOSFBtVXpKWVVUNm51SUV0clhsOHJUOU0zUE5xdkE0ODdsaUdRaTlSNWZRdllXVFBPUHJOamlJOVRqTjVkaDl1Mjh5a2EybFlKOE5xWWFuTHkvSGd1eUJmSFdOQVYyL1B6ZXZ6aXhlU2JkZENjQUsrcmNRTHVPSDgyb1BVcE9RTGVudE9mMXhsSTQ4dzVCTnBHeVNrQWltTmdEVGFsZHlyU055MmZoWU44QjI1T1NxbWZaVzdjeWtSeXVkeC9BOURkK0hRQTVpSjJhbE1DdXl0TE90NzJJYzlsU2NEVDlqUjlMNjJRbDQ0bG02UzlDbjJ4VGQ0M0FIT1JRWjkvcmNZNEFON09IRTZBMjR4SkNibmpOa2RBK3kxZEl4ajAyNXdCYmEvR0lkRFgyZytJOERPRnhENXpzVFIzMFpaL1FGcWo5MWhQVDlXMEFINlh6cUEydDUrUDlzdGZWcTJkcEYxK21FQXBEM051azR2VWdUcW84M1pLVWJ2cmF6emNxZjJjZ09mSDJidWlaNDdrZ1duUmZMYWRMKys1NXhpV3dCM0s5dEVKOE9QYXM0SEVMdU1JYUgyTkE1SzJJTGFlR1VUdFZEZ0VxZTNjZUV1T1FSeHJNdC84K3k3bDJzWFBpdEpNMWVmcWdFYjZRTmtacVBWYXNhQmZ1bjZnZ2R3ZDUyRU95RURYeGlKQjNaVW5neUw5MW9HZHRqOEc3bEhiQ3dNK3Fkc3g1R3ZhNGUwRjZGTlJBU3lWN2RnSmNQM2Fzd0ZnZmtkUUdudHRtc2paVkRnRXA1aldrK3FTWStCOUpHdVVnWHQ2RmxnSCtwTERrS1QyZ3UydUhVRnRwQS9VWGVSTjJxOTZCRE9EUjJrY1dXZWdwQnBxSXZXZ0xLZGllUHUxVVR0dGF3NjQ1K3lBUE9ENUdNYW1hcmp1MkhTTk9BWk5UL3RWdDU1OHk5WG5vT3pZQ1dqdHpPRUllSDM3ZzhMcW5FRnBISnBEa1Bwd3Ruby9TWHNxN0lmWE5jNWhxTSt2U2UxdjFPYTBXc2R3MEtUR0dhZ1hVeHY2NGIvUld3c2hxZjlzaEI0cTlFaWQ5OWNTc2RQMmFxNXJ6QUYzWUJuQWN6dEpmeGVRejdVWlhYdm9NOS9ZWFRrQnJjM1MyVUJ1TE55bXhSRUEwNTBCMVdtOXdKbExHYWw5RmZiTWg3b0s1OEQ3QTRUdHBoTWo4ZHdZZC9sRDUwdEs3a2ZVZ1Z5VVBjNkJWRUU4S09SaHpzZWgzK0JWaHJxenJ3ZTdYTGNjM0d2YXk5bUs5cFYyWXZzelE1NjNiZmgrVGUzTG1OdFpzU3Nud051YzZnakU0NG5PUU81anVrT2dZOU1mWlpEdmMyaG5oSU53aHFxdU5nNHYyYlRSRHZmZTAvbTJwR3JtRWkzYTF4eEUxUkNUZEU4dTJ1ZndLNmQzOU1zUmJWQ25aYVhyQk9wNEMzQVhiVVpHNzl4V2JhUFNUbXkvNHNKclRiczV5SFA1LytGZnBDUWdMOUdSQUFBQUFFbEZUa1N1UW1DQyIgdHJhbnNmb3JtPSJtYXRyaXgoMSwwLDAsLTEsMCwxKSIgaGVpZ2h0PSIxIiB3aWR0aD0iMSIgcHJlc2VydmVBc3BlY3RSYXRpbz0ibm9uZSIvPgogICAgPC9nPgogICA8L2c+CiAgPC9nPgogPC9nPgo8L3N2Zz4K\",\"data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnNjkzNCIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMjEwLjg3bW0iIHdpZHRoPSI0OS45NjZtbSIgdmVyc2lvbj0iMS4xIiB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmlld0JveD0iMCAwIDE3Ny4wNDUwMSA3NDcuMTYyNDkiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyI+CiA8ZGVmcyBpZD0iZGVmczY5MzYiPgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg2MTMzIiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNjEzNSIgY2xpcC1ydWxlPSJldmVub2RkIiBkPSJtNjIxNSAxOTAuMDNjMCA0OC4yMzkgMTQ1Ljg5IDg3LjM5MSAzMjUuOCA4Ny4zOTFzMzI1LjgtMzkuMTUyIDMyNS44LTg3LjM5MWMwLTQ4LjMzMi0xNDUuODktODcuNDgtMzI1LjgtODcuNDhzLTMyNS44IDM5LjE0OC0zMjUuOCA4Ny40OCIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDYwODEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MDgzIiBkPSJtNjQxNy45IDE1MzEuN2MtMzAuNDcgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnY0MTAyLjdjMCAzMC40NiAyNC42OSA1NS4xNiA1NS4xNiA1NS4xNiAzMC40NiAwIDU1LjE2LTI0LjcgNTUuMTYtNTUuMTZ2LTQxMDIuN2MwLTMwLjQ3LTI0LjctNTUuMTYtNTUuMTYtNTUuMTYiLz4KICA8L2NsaXBQYXRoPgogIDxsaW5lYXJHcmFkaWVudCBpZD0ibGluZWFyR3JhZGllbnQ2MDg1IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxZS03IC0yMzQ5LjEgLTIzNDkuMSAtMWUtNyA2NTQwLjggMzQ2OS4yKSI+CiAgIDxzdG9wIGlkPSJzdG9wNjA4NyIgc3RvcC1jb2xvcj0iI2YxNDg1ZiIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNjA4OSIgc3RvcC1jb2xvcj0iI2ExN2Q1YSIgb2Zmc2V0PSIxIi8+CiAgPC9saW5lYXJHcmFkaWVudD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNjA2MSIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDYwNjMiIGQ9Im02NTc3LjUgNTk0MS45aC03My40NGMtMTU0LjM3IDAtMjc5Ljk2LTEyNS41OS0yNzkuOTYtMjc5Ljk2di00MjA1LjVjLTIxOC4xLTExNi4yLTM1Ni4xLTM0My4yLTM1Ni4xLTU5My42NSAwLTM3MC45NiAzMDEuOC02NzIuNzUgNjcyLjgtNjcyLjc1IDM3MC45NiAwIDY3Mi43NSAzMDEuNzkgNjcyLjc1IDY3Mi43NSAwIDI1MC40OC0xMzcuOTYgNDc3LjQ4LTM1Ni4wNyA1OTMuN3Y0MjA1LjVjMCAxNTQuMzctMTI1LjU5IDI3OS45Ni0yNzkuOTYgMjc5Ljk2bTAtODguNThjMTA1LjI2IDAgMTkxLjM5LTg2LjEyIDE5MS4zOS0xOTEuMzh2LTQyNjEuMmMyMDkuMjgtODguODUgMzU2LjA3LTI5Ni4yNSAzNTYuMDctNTM3Ljk0IDAtMzIyLjYzLTI2MS41NS01ODQuMTgtNTg0LjE4LTU4NC4xOHMtNTg0LjE4IDI2MS41NS01ODQuMTggNTg0LjE4YzAgMjQxLjY5IDE0Ni43OSA0NDkuMDkgMzU2LjA3IDUzNy45NHY0MjYxLjJjMCAxMDUuMjYgODYuMTMgMTkxLjM4IDE5MS4zOSAxOTEuMzhoNzMuNDQiLz4KICA8L2NsaXBQYXRoPgogIDxsaW5lYXJHcmFkaWVudCBpZD0ibGluZWFyR3JhZGllbnQ2MDY1IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCgxMzQ1LjUgMCAwIC0xMzQ1LjUgNTg2OCAzMDY1LjkpIj4KICAgPHN0b3AgaWQ9InN0b3A2MDY3IiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A2MDY5IiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9Ii4xIi8+CiAgIDxzdG9wIGlkPSJzdG9wNjA3MSIgc3RvcC1jb2xvcj0iI2ZlZmZmZiIgb2Zmc2V0PSIuMjQ3MzEiLz4KICAgPHN0b3AgaWQ9InN0b3A2MDczIiBzdG9wLWNvbG9yPSIjYTNhM2ExIiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg2MDQ3IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNjA0OSIgZD0ibTY1NDAuOCA5NTUuODljLTE4OS4xNiAwLTM0Mi41IDk3LjIxOS0zNDIuNSAyMTcuMTUgMCAxMTkuOTIgMTUzLjM0IDIxNy4xNCAzNDIuNSAyMTcuMTQgMTI3LjgzIDAgMjM5LjMtNDQuNCAyOTguMTQtMTEwLjIgMjguMjMtMzEuNTYgNDQuMzUtNjguMDYgNDQuMzUtMTA2Ljk0IDAtNjUtNDUuMDQtMTIzLjMzLTExNi40Mi0xNjMuMTItNjAuMzItMzMuNjMxLTEzOS40NC01NC4wMjktMjI2LjA3LTU0LjAyOSIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDYwMzEiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MDMzIiBkPSJtNjc2Ni44IDEwMDkuOWM3MS4zOCAzOS43OSAxMTYuNDIgOTguMTIgMTE2LjQyIDE2My4xMnYtMS4yNGMtMC41Ni02NC41LTQ1LjQ5LTEyMi4zNC0xMTYuNDItMTYxLjg4bTExNi40MiAxNjMuMTJjMCAzOC44OC0xNi4xMiA3NS4zOC00NC4zNSAxMDYuOTQgMjcuOTQtMzEuMjMgNDQuMDItNjcuMjkgNDQuMzUtMTA1Ljcxdi0xLjIzIi8+CiAgPC9jbGlwUGF0aD4KICA8cmFkaWFsR3JhZGllbnQgaWQ9InJhZGlhbEdyYWRpZW50NjAzNSIgZ3JhZGllbnRVbml0cz0idXNlclNwYWNlT25Vc2UiIGN5PSIwIiBjeD0iMCIgZ3JhZGllbnRUcmFuc2Zvcm09Im1hdHJpeCg1ODQuMTggMCAwIC01ODQuMTggNjU0MC44IDg2Mi43NSkiIHI9IjEiPgogICA8c3RvcCBpZD0ic3RvcDYwMzciIHN0b3AtY29sb3I9IiNlZDFjMjQiIG9mZnNldD0iMCIvPgogICA8c3RvcCBpZD0ic3RvcDYwMzkiIHN0b3AtY29sb3I9IiM2MzJkMTYiIG9mZnNldD0iMSIvPgogIDwvcmFkaWFsR3JhZGllbnQ+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDYwMTUiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MDE3IiBkPSJtNjg4My4yIDExNzEuOHYxLjI0IDEuMjNjMC4wMS0wLjQxIDAuMDEtMC44MiAwLjAxLTEuMjNzMC0wLjgzLTAuMDEtMS4yNCIvPgogIDwvY2xpcFBhdGg+CiAgPGNsaXBQYXRoIGlkPSJjbGlwUGF0aDU5OTkiIGNsaXBQYXRoVW5pdHM9InVzZXJTcGFjZU9uVXNlIj4KICAgPHBhdGggaWQ9InBhdGg2MDAxIiBkPSJtNzEyNC45IDg2Mi43NWMwLTMyMi42NC0yNjEuNTUtNTg0LjE4LTU4NC4xOC01ODQuMThzLTU4NC4xOCAyNjEuNTQtNTg0LjE4IDU4NC4xOGMwIDMyMi42MyAyNjEuNTUgNTg0LjE4IDU4NC4xOCA1ODQuMThzNTg0LjE4LTI2MS41NSA1ODQuMTgtNTg0LjE4Ii8+CiAgPC9jbGlwUGF0aD4KICA8Y2xpcFBhdGggaWQ9ImNsaXBQYXRoNTk4MyIgY2xpcFBhdGhVbml0cz0idXNlclNwYWNlT25Vc2UiPgogICA8cGF0aCBpZD0icGF0aDU5ODUiIGQ9Im02MzEyLjYgMTQwMC43djQyNjEuMmMwIDEwNS4yNiA4Ni4xMyAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtNDI2MS4yYy03MC4wOCAyOS43Ni0xNDcuMTggNDYuMjItMjI4LjExIDQ2LjIycy0xNTguMDMtMTYuNDYtMjI4LjExLTQ2LjIybTEwNS4yMyA0MzQ0Yy0zMC40NyAwLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTQxMDIuN2MwLTMwLjQ3IDI0LjY5LTU1LjE2IDU1LjE2LTU1LjE2IDMwLjQ2IDAgNTUuMTYgMjQuNjkgNTUuMTYgNTUuMTZ2NDEwMi43YzAgMzAuNDYtMjQuNyA1NS4xNi01NS4xNiA1NS4xNiIvPgogIDwvY2xpcFBhdGg+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDU5ODciIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDFlLTcgLTIzNDkuMSAtMjM0OS4xIC0xZS03IDY1NDAuOCAzNDY5LjIpIj4KICAgPHN0b3AgaWQ9InN0b3A1OTg5IiBzdG9wLWNvbG9yPSIjZWQxYzI0IiBvZmZzZXQ9IjAiLz4KICAgPHN0b3AgaWQ9InN0b3A1OTkxIiBzdG9wLWNvbG9yPSIjNjMyZDE2IiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogIDxjbGlwUGF0aCBpZD0iY2xpcFBhdGg1OTY1IiBjbGlwUGF0aFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CiAgIDxwYXRoIGlkPSJwYXRoNTk2NyIgZD0ibTY3NjguOSAxMzI2LjhjMC0xMDUuMjYtODYuMTMtMTkxLjM5LTE5MS4zOS0xOTEuMzloLTczLjQ0Yy0xMDUuMjYgMC0xOTEuMzkgODYuMTMtMTkxLjM5IDE5MS4zOXY0MzM1LjJjMCAxMDUuMjYgODYuMTMgMTkxLjM4IDE5MS4zOSAxOTEuMzhoNzMuNDRjMTA1LjI2IDAgMTkxLjM5LTg2LjEyIDE5MS4zOS0xOTEuMzh2LTQzMzUuMiIvPgogIDwvY2xpcFBhdGg+CiAgPGxpbmVhckdyYWRpZW50IGlkPSJsaW5lYXJHcmFkaWVudDU5NjkiIGdyYWRpZW50VW5pdHM9InVzZXJTcGFjZU9uVXNlIiBncmFkaWVudFRyYW5zZm9ybT0ibWF0cml4KDQ1Ni4yMiAwIDAgLTQ1Ni4yMiA2MzEyLjYgMzQ5NC4zKSI+CiAgIDxzdG9wIGlkPSJzdG9wNTk3MSIgc3RvcC1jb2xvcj0iI2EzYTNhMSIgb2Zmc2V0PSIwIi8+CiAgIDxzdG9wIGlkPSJzdG9wNTk3MyIgc3RvcC1jb2xvcj0iI2VjZWNlYyIgb2Zmc2V0PSIuNzUyNjkiLz4KICAgPHN0b3AgaWQ9InN0b3A1OTc1IiBzdG9wLWNvbG9yPSIjYjNiM2IyIiBvZmZzZXQ9IjEiLz4KICA8L2xpbmVhckdyYWRpZW50PgogPC9kZWZzPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTIzMS4zNyAtMTc2LjY3KSI+CiAgPGcgZmlsbD0iI2ZmZiI+CiAgIDxwYXRoIGlkPSJwYXRoNTU3OSIgZD0ibTMxOS44OSA5MjMuODNjLTQ4Ljg4OSAwLTg4LjUyMi0xMC42MzItODguNTIyLTIzLjc1IDAtNy4wODY0IDExLjU3Mi0xMy40NDkgMjkuOTE5LTE3LjggMC4wMzYyIDAuMDI5OCAwLjA2NzUgMC4wNTg1IDAuMTAyNSAwLjA4OTctMTguMjE5IDQuMzM1LTI5LjcgMTAuNjYzLTI5LjcgMTcuNzEgMCAxMy4wNjkgMzkuNDg5IDIzLjY2NCA4OC4yMDEgMjMuNjY0czg4LjIwMS0xMC41OTUgODguMjAxLTIzLjY2NGMwLTcuMDQ3NC0xMS40OC0xMy4zNzUtMjkuNy0xNy43MSAwLjAzNS0wLjAzMTIgMC4wNjc1LTAuMDU4NSAwLjEwMjUtMC4wODk3IDE4LjM0OCA0LjM1MSAyOS45MTggMTAuNzEzIDI5LjkxOCAxNy44IDAgMTMuMTE4LTM5LjYzMiAyMy43NS04OC41MjEgMjMuNzUiLz4KICAgPHBhdGggaWQ9InBhdGg1NTgxIiBkPSJtMzE5Ljg5IDkyMy43NGMtNDguNzEyIDAtODguMjAxLTEwLjU5NS04OC4yMDEtMjMuNjY0IDAtNy4wNDc0IDExLjQ4MS0xMy4zNzUgMjkuNy0xNy43MSAwLjAzMzcgMC4wMjg5IDAuMDcgMC4wNjExIDAuMTAzNzUgMC4wOTE0LTE4LjA5IDQuMzE3NC0yOS40ODIgMTAuNjExLTI5LjQ4MiAxNy42MTkgMCAxMy4wMjEgMzkuMzQ1IDIzLjU3OCA4Ny44OCAyMy41NzhzODcuODgtMTAuNTU3IDg3Ljg4LTIzLjU3OGMwLTcuMDA3NC0xMS4zOTItMTMuMzAxLTI5LjQ4Mi0xNy42MTkgMC4wMzM4LTAuMDMwMiAwLjA3LTAuMDYyNSAwLjEwMzc1LTAuMDkxNCAxOC4yMiA0LjMzNSAyOS43IDEwLjY2MyAyOS43IDE3LjcxIDAgMTMuMDY5LTM5LjQ4OSAyMy42NjQtODguMjAxIDIzLjY2NCIvPgogICA8cGF0aCBpZD0icGF0aDU1ODMiIGQ9Im0zMTkuODkgOTIzLjY2Yy00OC41MzUgMC04Ny44OC0xMC41NTctODcuODgtMjMuNTc4IDAtNy4wMDc0IDExLjM5Mi0xMy4zMDEgMjkuNDgyLTE3LjYxOSAwLjAzNSAwLjAyOTcgMC4wNjc1IDAuMDU4NiAwLjEwMjUgMC4wODk5LTE3Ljk1OSA0LjMwMjYtMjkuMjY1IDEwLjU2Mi0yOS4yNjUgMTcuNTI5IDAgMTIuOTc0IDM5LjIwMiAyMy40OTEgODcuNTYgMjMuNDkxczg3LjU2LTEwLjUxOCA4Ny41Ni0yMy40OTFjMC02Ljk2NzItMTEuMzA2LTEzLjIyNi0yOS4yNjUtMTcuNTI5IDAuMDM1LTAuMDMxMiAwLjA2NzUtMC4wNjAxIDAuMTAyNS0wLjA4OTkgMTguMDkgNC4zMTc0IDI5LjQ4MiAxMC42MTEgMjkuNDgyIDE3LjYxOSAwIDEzLjAyMS0zOS4zNDUgMjMuNTc4LTg3Ljg4IDIzLjU3OCIvPgogICA8cGF0aCBpZD0icGF0aDU1ODUiIGQ9Im0zMTkuODkgOTIzLjU3Yy00OC4zNTggMC04Ny41Ni0xMC41MTgtODcuNTYtMjMuNDkxIDAtNi45NjcyIDExLjMwNi0xMy4yMjYgMjkuMjY1LTE3LjUyOSAwLjAzMzcgMC4wMjg4IDAuMDcgMC4wNjEgMC4xMDM3NSAwLjA4OTctMTcuODMgNC4yODY2LTI5LjA0OCAxMC41MTEtMjkuMDQ4IDE3LjQzOSAwIDEyLjkyNiAzOS4wNTggMjMuNDA2IDg3LjIzOSAyMy40MDYgNDguMTggMCA4Ny4yMzktMTAuNDggODcuMjM5LTIzLjQwNiAwLTYuOTI3OC0xMS4yMTgtMTMuMTUyLTI5LjA0OC0xNy40MzggMC4wMzM3LTAuMDMwMyAwLjA3LTAuMDYyNSAwLjEwMzc1LTAuMDkxMyAxNy45NTkgNC4zMDI2IDI5LjI2NSAxMC41NjIgMjkuMjY1IDE3LjUyOSAwIDEyLjk3NC0zOS4yMDIgMjMuNDkxLTg3LjU2IDIzLjQ5MSIvPgogICA8cGF0aCBpZD0icGF0aDU1ODciIGQ9Im0zMTkuODkgOTIzLjQ5Yy00OC4xODEgMC04Ny4yMzktMTAuNDgtODcuMjM5LTIzLjQwNiAwLTYuOTI3OCAxMS4yMTgtMTMuMTUyIDI5LjA0OC0xNy40MzkgMC4wMzUgMC4wMzEyIDAuMDY4NyAwLjA2MDEgMC4xMDM3NSAwLjA5MTQtMTcuNzAxIDQuMjY5LTI4LjgzMSAxMC40NTktMjguODMxIDE3LjM0OCAwIDEyLjg3OSAzOC45MTUgMjMuMzIgODYuOTE5IDIzLjMyczg2LjkxOC0xMC40NDEgODYuOTE4LTIzLjMyYzAtNi44ODg2LTExLjEyOS0xMy4wNzktMjguODMtMTcuMzQ4IDAuMDM1LTAuMDMxMiAwLjA2ODctMC4wNjAxIDAuMTAzNzUtMC4wODk5IDE3LjgzIDQuMjg1MSAyOS4wNDggMTAuNTEgMjkuMDQ4IDE3LjQzOCAwIDEyLjkyNi0zOS4wNTkgMjMuNDA2LTg3LjIzOSAyMy40MDYiLz4KICAgPHBhdGggaWQ9InBhdGg1NTg5IiBkPSJtMzE5Ljg5IDkyMy40Yy00OC4wMDQgMC04Ni45MTktMTAuNDQxLTg2LjkxOS0yMy4zMiAwLTYuODg4NiAxMS4xMy0xMy4wNzkgMjguODMxLTE3LjM0OCAwLjAzNjIgMC4wMzEyIDAuMDY4NyAwLjA2IDAuMTA1IDAuMDkwMi0xNy41NyA0LjI1MjUtMjguNjE1IDEwLjQxLTI4LjYxNSAxNy4yNTcgMCAxMi44MzEgMzguNzcxIDIzLjIzNCA4Ni41OTggMjMuMjM0IDQ3LjgyNiAwIDg2LjU5OC0xMC40MDMgODYuNTk4LTIzLjIzNCAwLTYuODQ3Ni0xMS4wNDUtMTMuMDA1LTI4LjYxNC0xNy4yNTYgMC4wMzUtMC4wMzEyIDAuMDY4Ny0wLjA2IDAuMTAzNzUtMC4wOTEyIDE3LjcwMSA0LjI2OSAyOC44MyAxMC40NTkgMjguODMgMTcuMzQ4IDAgMTIuODc5LTM4LjkxNCAyMy4zMi04Ni45MTggMjMuMzIiLz4KICAgPHBhdGggaWQ9InBhdGg1NTkxIiBkPSJtMzE5Ljg5IDkyMy4zMWMtNDcuODI2IDAtODYuNTk4LTEwLjQwMy04Ni41OTgtMjMuMjM0IDAtNi44NDc2IDExLjA0NS0xMy4wMDUgMjguNjE1LTE3LjI1NyAwLjAzMjUgMC4wMjk5IDAuMDcgMC4wNjI1IDAuMTAzNzUgMC4wOTE0LTE3LjQ0MSA0LjIzNDktMjguMzk4IDEwLjM1Ny0yOC4zOTggMTcuMTY2IDAgMTIuNzg0IDM4LjYyNiAyMy4xNDcgODYuMjc2IDIzLjE0NyA0Ny42NDkgMCA4Ni4yNzYtMTAuMzY0IDg2LjI3Ni0yMy4xNDcgMC02LjgwODYtMTAuOTU2LTEyLjkzMS0yOC4zOTgtMTcuMTY2IDAuMDMzNy0wLjAyODkgMC4wNzEyLTAuMDYxNSAwLjEwNS0wLjA5MDQgMTcuNTY5IDQuMjUxNSAyOC42MTQgMTAuNDA5IDI4LjYxNCAxNy4yNTYgMCAxMi44MzEtMzguNzcxIDIzLjIzNC04Ni41OTggMjMuMjM0Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTU5MyIgZD0ibTMxOS44OSA5MjMuMjNjLTQ3LjY1IDAtODYuMjc2LTEwLjM2NC04Ni4yNzYtMjMuMTQ4IDAtNi44MDg2IDEwLjk1Ni0xMi45MzEgMjguMzk4LTE3LjE2NiAwLjAzNjIgMC4wMzA4IDAuMDY4NyAwLjA1OTUgMC4xMDUgMC4wODk5LTE3LjMxNCA0LjIxOTYtMjguMTgyIDEwLjMwOC0yOC4xODIgMTcuMDc2IDAgMTIuNzM2IDM4LjQ4NCAyMy4wNjEgODUuOTU2IDIzLjA2MXM4NS45NTYtMTAuMzI1IDg1Ljk1Ni0yMy4wNjFjMC02Ljc2ODUtMTAuODcyLTEyLjg1Ni0yOC4xODItMTcuMDc1IDAuMDM2My0wLjAzMTIgMC4wNy0wLjA2IDAuMTA1LTAuMDkwOCAxNy40NDEgNC4yMzQ5IDI4LjM5OCAxMC4zNTcgMjguMzk4IDE3LjE2NiAwIDEyLjc4NC0zOC42MjggMjMuMTQ3LTg2LjI3NiAyMy4xNDciLz4KICAgPHBhdGggaWQ9InBhdGg1NTk1IiBkPSJtMzE5Ljg5IDkyMy4xNGMtNDcuNDcyIDAtODUuOTU2LTEwLjMyNS04NS45NTYtMjMuMDYxIDAtNi43Njg1IDEwLjg2OS0xMi44NTYgMjguMTgyLTE3LjA3NiAwLjAzNSAwLjAzMTIgMC4wNyAwLjA2MSAwLjEwNSAwLjA5MTMtMTcuMTg0IDQuMjAyMS0yNy45NjYgMTAuMjU2LTI3Ljk2NiAxNi45ODUgMCAxMi42ODggMzguMzQgMjIuOTc2IDg1LjYzNSAyMi45NzZzODUuNjM1LTEwLjI4OCA4NS42MzUtMjIuOTc2YzAtNi43MjktMTAuNzgyLTEyLjc4My0yNy45NjYtMTYuOTg1IDAuMDM1LTAuMDMwMyAwLjA3LTAuMDYgMC4xMDUtMC4wOTA0IDE3LjMxIDQuMjE4OCAyOC4xODIgMTAuMzA3IDI4LjE4MiAxNy4wNzUgMCAxMi43MzYtMzguNDg0IDIzLjA2MS04NS45NTYgMjMuMDYxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTU5NyIgZD0ibTMxOS44OSA5MjMuMDZjLTQ3LjI5NSAwLTg1LjYzNS0xMC4yODgtODUuNjM1LTIyLjk3NiAwLTYuNzI5IDEwLjc4Mi0xMi43ODMgMjcuOTY2LTE2Ljk4NSAwLjAzMzcgMC4wMjg5IDAuMDcyNSAwLjA2MjUgMC4xMDYyNSAwLjA5MTQtMTcuMDU1IDQuMTg0NS0yNy43NTIgMTAuMjA0LTI3Ljc1MiAxNi44OTQgMCAxMi42NDEgMzguMTk4IDIyLjg5IDg1LjMxNSAyMi44OSA0Ny4xMTggMCA4NS4zMTQtMTAuMjQ5IDg1LjMxNC0yMi44OSAwLTYuNjg5LTEwLjY5Ni0xMi43MDktMjcuNzUxLTE2Ljg5NCAwLjAzMzctMC4wMjg5IDAuMDcyNS0wLjA2MjUgMC4xMDYyNS0wLjA5MTQgMTcuMTg0IDQuMjAyMSAyNy45NjYgMTAuMjU2IDI3Ljk2NiAxNi45ODUgMCAxMi42ODgtMzguMzQgMjIuOTc2LTg1LjYzNSAyMi45NzYiLz4KICAgPHBhdGggaWQ9InBhdGg1NTk5IiBkPSJtMzE5Ljg5IDkyMi45N2MtNDcuMTE4IDAtODUuMzE1LTEwLjI0OS04NS4zMTUtMjIuODkgMC02LjY4OSAxMC42OTgtMTIuNzA5IDI3Ljc1Mi0xNi44OTQgMC4wMzUgMC4wMjk4IDAuMDcgMC4wNTk1IDAuMTA1IDAuMDg5Ny0xNi45MjUgNC4xNjktMjcuNTM2IDEwLjE1NC0yNy41MzYgMTYuODA0IDAgMTIuNTk0IDM4LjA1MiAyMi44MDQgODQuOTk0IDIyLjgwNCA0Ni45NCAwIDg0Ljk5NC0xMC4yMSA4NC45OTQtMjIuODA0IDAtNi42NDk5LTEwLjYxMS0xMi42MzUtMjcuNTM2LTE2LjgwNCAwLjAzNS0wLjAzMDMgMC4wNy0wLjA2IDAuMTA1LTAuMDg5NyAxNy4wNTUgNC4xODQ1IDI3Ljc1MSAxMC4yMDQgMjcuNzUxIDE2Ljg5NCAwIDEyLjY0MS0zOC4xOTYgMjIuODktODUuMzE0IDIyLjg5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTYwMSIgZD0ibTMxOS44OSA5MjIuODhjLTQ2Ljk0MSAwLTg0Ljk5NC0xMC4yMS04NC45OTQtMjIuODA0IDAtNi42NDk5IDEwLjYxMS0xMi42MzUgMjcuNTM2LTE2LjgwNCAwLjAzNjIgMC4wMzEyIDAuMDcxMyAwLjA2MDEgMC4xMDYyNSAwLjA5MTQtMTYuNzk5IDQuMTUxNC0yNy4zMjEgMTAuMTAxLTI3LjMyMSAxNi43MTIgMCAxMi41NDYgMzcuOTA5IDIyLjcxNyA4NC42NzIgMjIuNzE3IDQ2Ljc2NCAwIDg0LjY3Mi0xMC4xNzEgODQuNjcyLTIyLjcxNyAwLTYuNjA5OS0xMC41MjUtMTIuNTYxLTI3LjMyMS0xNi43MTIgMC4wMzUtMC4wMzEyIDAuMDctMC4wNjAxIDAuMTA2MjUtMC4wOTE0IDE2LjkyNSA0LjE2OSAyNy41MzYgMTAuMTU0IDI3LjUzNiAxNi44MDQgMCAxMi41OTQtMzguMDU0IDIyLjgwNC04NC45OTQgMjIuODA0Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTYwMyIgZD0ibTMxOS44OSA5MjIuOGMtNDYuNzY0IDAtODQuNjcyLTEwLjE3MS04NC42NzItMjIuNzE3IDAtNi42MTE0IDEwLjUyMi0xMi41NjEgMjcuMzIxLTE2LjcxMiAwLjAzNjIgMC4wMjk3IDAuMDcxMyAwLjA2IDAuMTA3NSAwLjA4OTktMTYuNjY5IDQuMTM1Mi0yNy4xMDkgMTAuMDUzLTI3LjEwOSAxNi42MjIgMCAxMi40OTkgMzcuNzY2IDIyLjYzMSA4NC4zNTIgMjIuNjMxczg0LjM1MS0xMC4xMzMgODQuMzUxLTIyLjYzMWMwLTYuNTY5OS0xMC40MzktMTIuNDg3LTI3LjEwNi0xNi42MjIgMC4wMzUtMC4wMjk5IDAuMDctMC4wNjAxIDAuMTA2MjUtMC4wODk5IDE2Ljc5NiA0LjE1MTQgMjcuMzIxIDEwLjEwMiAyNy4zMjEgMTYuNzEyIDAgMTIuNTQ2LTM3LjkwOSAyMi43MTctODQuNjcyIDIyLjcxNyIvPgogICA8cGF0aCBpZD0icGF0aDU2MDUiIGQ9Im0zMTkuODkgOTIyLjcxYy00Ni41ODYgMC04NC4zNTItMTAuMTMzLTg0LjM1Mi0yMi42MzEgMC02LjU2OTkgMTAuNDQtMTIuNDg3IDI3LjEwOS0xNi42MjIgMC4wMzUgMC4wMzEyIDAuMDcgMC4wNjE1IDAuMTA2MjUgMC4wOTEzLTE2LjU0MSA0LjExNzYtMjYuODk0IDEwLTI2Ljg5NCAxNi41MzEgMCAxMi40NTEgMzcuNjIyIDIyLjU0NSA4NC4wMzEgMjIuNTQ1czg0LjAzMS0xMC4wOTQgODQuMDMxLTIyLjU0NWMwLTYuNTMxMi0xMC4zNTItMTIuNDE0LTI2Ljg5NC0xNi41MzEgMC4wMzYyLTAuMDI5OCAwLjA3MTItMC4wNiAwLjEwNzUtMC4wOTEzIDE2LjY2OCA0LjEzNTIgMjcuMTA2IDEwLjA1MyAyNy4xMDYgMTYuNjIyIDAgMTIuNDk5LTM3Ljc2NSAyMi42MzEtODQuMzUxIDIyLjYzMSIvPgogICA8cGF0aCBpZD0icGF0aDU2MDciIGQ9Im0zMTkuODkgOTIyLjYyYy00Ni40MDkgMC04NC4wMzEtMTAuMDk0LTg0LjAzMS0yMi41NDUgMC02LjUzMTIgMTAuMzUyLTEyLjQxNCAyNi44OTQtMTYuNTMxIDAuMDM2MiAwLjAyOTcgMC4wNzEzIDAuMDYgMC4xMDc1IDAuMDg5OS0xNi40MTQgNC4xMDE1LTI2LjY4MSA5Ljk1MDEtMjYuNjgxIDE2LjQ0MSAwIDEyLjQwNCAzNy40NzkgMjIuNDU5IDgzLjcxMSAyMi40NTlzODMuNzEtMTAuMDU1IDgzLjcxLTIyLjQ1OWMwLTYuNDkxMi0xMC4yNjUtMTIuMzQtMjYuNjgtMTYuNDQgMC4wMzYyLTAuMDMxMiAwLjA3MTItMC4wNjE1IDAuMTA3NS0wLjA5MTIgMTYuNTQxIDQuMTE3NiAyNi44OTQgMTAgMjYuODk0IDE2LjUzMSAwIDEyLjQ1MS0zNy42MjIgMjIuNTQ1LTg0LjAzMSAyMi41NDUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjA5IiBkPSJtMzE5Ljg5IDkyMi41NGMtNDYuMjMyIDAtODMuNzExLTEwLjA1NS04My43MTEtMjIuNDU5IDAtNi40OTEyIDEwLjI2OC0xMi4zNCAyNi42ODEtMTYuNDQxIDAuMDM2MiAwLjAzMTIgMC4wNzEyIDAuMDYxNSAwLjEwNzUgMC4wOTEyLTE2LjI4OCA0LjA4NC0yNi40NjggOS44OTc1LTI2LjQ2OCAxNi4zNSAwIDEyLjM1NiAzNy4zMzUgMjIuMzczIDgzLjM5IDIyLjM3M3M4My4zOS0xMC4wMTYgODMuMzktMjIuMzczYzAtNi40NTI2LTEwLjE4LTEyLjI2Ni0yNi40NjgtMTYuMzUgMC4wMzYyLTAuMDI5NyAwLjA3MTMtMC4wNiAwLjEwNzUtMC4wODk5IDE2LjQxNSA0LjEwMDEgMjYuNjggOS45NDg4IDI2LjY4IDE2LjQ0IDAgMTIuNDA0LTM3LjQ3OCAyMi40NTktODMuNzEgMjIuNDU5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTYxMSIgZD0ibTMxOS44OSA5MjIuNDVjLTQ2LjA1NSAwLTgzLjM5LTEwLjAxNi04My4zOS0yMi4zNzMgMC02LjQ1MjYgMTAuMTgtMTIuMjY2IDI2LjQ2OC0xNi4zNSAwLjAzNjMgMC4wMzAzIDAuMDcyNSAwLjA2MDEgMC4xMDg3NSAwLjA5MTQtMTYuMTYgNC4wNjY0LTI2LjI1NSA5Ljg0NjEtMjYuMjU1IDE2LjI1OSAwIDEyLjMwOSAzNy4xOTEgMjIuMjg4IDgzLjA2OSAyMi4yODhzODMuMDY5LTkuOTc5IDgzLjA2OS0yMi4yODhjMC02LjQxMjYtMTAuMDk1LTEyLjE5Mi0yNi4yNTQtMTYuMjU5IDAuMDM1LTAuMDI5OSAwLjA3MTItMC4wNjExIDAuMTA3NS0wLjA5MTQgMTYuMjg4IDQuMDg0IDI2LjQ2OCA5Ljg5NzUgMjYuNDY4IDE2LjM1IDAgMTIuMzU2LTM3LjMzNSAyMi4zNzMtODMuMzkgMjIuMzczIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTYxMyIgZD0ibTMxOS44OSA5MjIuMzdjLTQ1Ljg3OCAwLTgzLjA2OS05Ljk3OS04My4wNjktMjIuMjg4IDAtNi40MTI2IDEwLjA5NS0xMi4xOTIgMjYuMjU1LTE2LjI1OSAwLjAzNSAwLjAzMDMgMC4wNzEyIDAuMDYgMC4xMDc1IDAuMDg5OC0xNi4wMyA0LjA0ODktMjYuMDQyIDkuNzk2NS0yNi4wNDIgMTYuMTY5IDAgMTIuMjYxIDM3LjA0OSAyMi4yMDEgODIuNzQ5IDIyLjIwMXM4Mi43NDgtOS45Mzk5IDgyLjc0OC0yMi4yMDFjMC02LjM3MjUtMTAuMDExLTEyLjEyLTI2LjA0MS0xNi4xNjkgMC4wMzYyLTAuMDI5OCAwLjA3MjUtMC4wNTk1IDAuMTA4NzUtMC4wODk4IDE2LjE1OSA0LjA2NjQgMjYuMjU0IDkuODQ2MSAyNi4yNTQgMTYuMjU5IDAgMTIuMzA5LTM3LjE5MSAyMi4yODgtODMuMDY5IDIyLjI4OCIvPgogICA8cGF0aCBpZD0icGF0aDU2MTUiIGQ9Im0zMTkuODkgOTIyLjI4Yy00NS43IDAtODIuNzQ5LTkuOTQtODIuNzQ5LTIyLjIwMSAwLTYuMzcyNSAxMC4wMTItMTIuMTIgMjYuMDQyLTE2LjE2OSAwLjAzNjMgMC4wMzA0IDAuMDcyNSAwLjA2MTYgMC4xMDg3NSAwLjA5MTQtMTUuOTA2IDQuMDMxMi0yNS44MyA5Ljc0NDEtMjUuODMgMTYuMDc4IDAgMTIuMjE0IDM2LjkwNCAyMi4xMTUgODIuNDI4IDIyLjExNXM4Mi40MjgtOS45MDA5IDgyLjQyOC0yMi4xMTVjMC02LjMzMzUtOS45MjM4LTEyLjA0Ni0yNS44My0xNi4wNzggMC4wMzYyLTAuMDI5OCAwLjA3MjUtMC4wNjAxIDAuMTA4NzUtMC4wOTE0IDE2LjAzIDQuMDQ4OSAyNi4wNDEgOS43OTY1IDI2LjA0MSAxNi4xNjkgMCAxMi4yNjEtMzcuMDQ4IDIyLjIwMS04Mi43NDggMjIuMjAxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTYxNyIgZD0ibTMxOS44OSA5MjIuMTljLTQ1LjUyNCAwLTgyLjQyOC05LjkwMDgtODIuNDI4LTIyLjExNSAwLTYuMzMzNSA5LjkyMzgtMTIuMDQ2IDI1LjgzLTE2LjA3OCAwLjAzNjIgMC4wMzAzIDAuMDczNyAwLjA2MTUgMC4xMSAwLjA5MTMtMTUuNzggNC4wMTM4LTI1LjYxOSA5LjY5MTUtMjUuNjE5IDE1Ljk4NiAwIDEyLjE2NiAzNi43NiAyMi4wMjkgODIuMTA2IDIyLjAyOXM4Mi4xMDYtOS44NjI4IDgyLjEwNi0yMi4wMjljMC02LjI5NDktOS44Mzg4LTExLjk3My0yNS42MTgtMTUuOTg2IDAuMDM2My0wLjAyOTcgMC4wNzI1LTAuMDYxIDAuMTA4NzUtMC4wOTEzIDE1LjkwNiA0LjAzMTIgMjUuODMgOS43NDQxIDI1LjgzIDE2LjA3OCAwIDEyLjIxNC0zNi45MDQgMjIuMTE1LTgyLjQyOCAyMi4xMTUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjE5IiBkPSJtMzE5Ljg5IDkyMi4xMWMtNDUuMzQ2IDAtODIuMTA2LTkuODYyOC04Mi4xMDYtMjIuMDI5IDAtNi4yOTQ5IDkuODM4OC0xMS45NzMgMjUuNjE5LTE1Ljk4NiAwLjAzNSAwLjAzMDQgMC4wNzI1IDAuMDYwMSAwLjEwODc1IDAuMDg5OS0xNS42NTIgMy45OTc2LTI1LjQwOCA5LjY0MTYtMjUuNDA4IDE1Ljg5NiAwIDEyLjExOSAzNi42MTggMjEuOTQyIDgxLjc4NiAyMS45NDIgNDUuMTY5IDAgODEuNzg2LTkuODIzNyA4MS43ODYtMjEuOTQyIDAtNi4yNTQ5LTkuNzU1LTExLjg5OS0yNS40MDgtMTUuODk2IDAuMDM2Mi0wLjAyOTcgMC4wNzM3LTAuMDU5NSAwLjExLTAuMDg5OSAxNS43NzkgNC4wMTM4IDI1LjYxOCA5LjY5MTUgMjUuNjE4IDE1Ljk4NiAwIDEyLjE2Ni0zNi43NiAyMi4wMjktODIuMTA2IDIyLjAyOSIvPgogICA8cGF0aCBpZD0icGF0aDU2MjEiIGQ9Im0zMTkuODkgOTIyLjAyYy00NS4xNjkgMC04MS43ODYtOS44MjM4LTgxLjc4Ni0yMS45NDIgMC02LjI1NDkgOS43NTUtMTEuODk5IDI1LjQwOC0xNS44OTYgMC4wMzYyIDAuMDMwMyAwLjA3MzcgMC4wNjE1IDAuMTEgMC4wOTE0LTE1LjUyNiAzLjk3OTktMjUuMTk2IDkuNTg4OS0yNS4xOTYgMTUuODA1IDAgMTIuMDcxIDM2LjQ3MiAyMS44NTYgODEuNDY1IDIxLjg1NiA0NC45OTEgMCA4MS40NjUtOS43ODUyIDgxLjQ2NS0yMS44NTYgMC02LjIxNjItOS42Ny0xMS44MjUtMjUuMTk2LTE1LjgwNSAwLjAzNjItMC4wMjk5IDAuMDczOC0wLjA2MTEgMC4xMS0wLjA5MTQgMTUuNjUyIDMuOTk3NiAyNS40MDggOS42NDE2IDI1LjQwOCAxNS44OTYgMCAxMi4xMTktMzYuNjE4IDIxLjk0Mi04MS43ODYgMjEuOTQyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTYyMyIgZD0ibTMxOS44OSA5MjEuOTRjLTQ0Ljk5MiAwLTgxLjQ2NS05Ljc4NTEtODEuNDY1LTIxLjg1NiAwLTYuMjE2MiA5LjY3LTExLjgyNSAyNS4xOTYtMTUuODA1IDAuMDM2MyAwLjAzMDIgMC4wNzM4IDAuMDYxNSAwLjExIDAuMDkxMi0xNS4zOTggMy45NjE0LTI0Ljk4NSA5LjUzNzYtMjQuOTg1IDE1LjcxNCAwIDEyLjAyNCAzNi4zMjkgMjEuNzcgODEuMTQ0IDIxLjc3czgxLjE0NC05Ljc0NjEgODEuMTQ0LTIxLjc3YzAtNi4xNzYyLTkuNTg3NS0xMS43NTItMjQuOTg1LTE1LjcxNCAwLjAzNjItMC4wMjk3IDAuMDczNy0wLjA2MSAwLjExLTAuMDkxMiAxNS41MjYgMy45Nzk5IDI1LjE5NiA5LjU4ODkgMjUuMTk2IDE1LjgwNSAwIDEyLjA3MS0zNi40NzQgMjEuODU2LTgxLjQ2NSAyMS44NTYiLz4KICAgPHBhdGggaWQ9InBhdGg1NjI1IiBkPSJtMzE5Ljg5IDkyMS44NWMtNDQuODE1IDAtODEuMTQ0LTkuNzQ2MS04MS4xNDQtMjEuNzcgMC02LjE3NjIgOS41ODc1LTExLjc1MiAyNC45ODUtMTUuNzE0IDAuMDM2MiAwLjAyODkgMC4wNzM4IDAuMDYwMSAwLjExMTI1IDAuMDkwNC0xNS4yNzQgMy45NDQ4LTI0Ljc3NiA5LjQ4NTgtMjQuNzc2IDE1LjYyNCAwIDExLjk3NiAzNi4xODYgMjEuNjg1IDgwLjgyNCAyMS42ODVzODAuODI0LTkuNzA5IDgwLjgyNC0yMS42ODVjMC02LjEzNzgtOS41MDI1LTExLjY3OS0yNC43NzUtMTUuNjI0IDAuMDM2Mi0wLjAzMDMgMC4wNzM4LTAuMDYwMSAwLjExLTAuMDkwNCAxNS4zOTggMy45NjE0IDI0Ljk4NSA5LjUzNzYgMjQuOTg1IDE1LjcxNCAwIDEyLjAyNC0zNi4zMjkgMjEuNzctODEuMTQ0IDIxLjc3Ii8+CiAgPC9nPgogIDxnPgogICA8cGF0aCBpZD0icGF0aDU2MjciIGQ9Im0zMTkuODkgOTIxLjc3Yy00NC42MzggMC04MC44MjQtOS43MDktODAuODI0LTIxLjY4NSAwLTYuMTM3OCA5LjUwMjUtMTEuNjc5IDI0Ljc3Ni0xNS42MjQgMC4wMzc1IDAuMDMwOCAwLjA3MTMgMC4wNTk1IDAuMTEgMC4wOTA3LTE1LjE0NSAzLjkyNzgtMjQuNTY1IDkuNDM1Mi0yNC41NjUgMTUuNTMzIDAgMTEuOTI5IDM2LjA0MiAyMS41OTkgODAuNTAyIDIxLjU5OXM4MC41MDItOS42Njk5IDgwLjUwMi0yMS41OTljMC02LjA5NzYtOS40Mi0xMS42MDUtMjQuNTY1LTE1LjUzMyAwLjAzODctMC4wMzEyIDAuMDcyNS0wLjA1ODUgMC4xMTEyNS0wLjA5MDcgMTUuMjcyIDMuOTQ0OCAyNC43NzUgOS40ODU4IDI0Ljc3NSAxNS42MjQgMCAxMS45NzYtMzYuMTg2IDIxLjY4NS04MC44MjQgMjEuNjg1IiBmaWxsPSIjZmVmZmZmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTYyOSIgZD0ibTMxOS44OSA5MjEuNjhjLTQ0LjQ2IDAtODAuNTAyLTkuNjY5OS04MC41MDItMjEuNTk5IDAtNi4wOTc2IDkuNDItMTEuNjA1IDI0LjU2NS0xNS41MzMgMC4wMzYyIDAuMDMwMyAwLjA3NSAwLjA2MTUgMC4xMTEyNSAwLjA5MTQtMTUuMDIxIDMuOTA4Ni0yNC4zNTYgOS4zODI3LTI0LjM1NiAxNS40NDEgMCAxMS44ODEgMzUuODk5IDIxLjUxMyA4MC4xODIgMjEuNTEzIDQ0LjI4NCAwIDgwLjE4Mi05LjYzMTQgODAuMTgyLTIxLjUxMyAwLTYuMDU4Ni05LjMzNS0xMS41MzEtMjQuMzU2LTE1LjQ0MSAwLjAzNjItMC4wMjk5IDAuMDc1LTAuMDYxMSAwLjExMTI1LTAuMDkxNCAxNS4xNDUgMy45Mjc4IDI0LjU2NSA5LjQzNTEgMjQuNTY1IDE1LjUzMyAwIDExLjkyOS0zNi4wNDIgMjEuNTk5LTgwLjUwMiAyMS41OTkiIGZpbGw9IiNmZWZmZmYiLz4KICAgPHBhdGggaWQ9InBhdGg1NjMxIiBkPSJtMzE5Ljg5IDkyMS41OWMtNDQuMjg0IDAtODAuMTgyLTkuNjMxNC04MC4xODItMjEuNTEzIDAtNi4wNTg2IDkuMzM1LTExLjUzMyAyNC4zNTYtMTUuNDQxIDAuMDM3NSAwLjAyODcgMC4wNzYyIDAuMDYgMC4xMTI1IDAuMDkwMi0xNC44OTYgMy44OTIxLTI0LjE0OCA5LjMzMjYtMjQuMTQ4IDE1LjM1MSAwIDExLjgzNCAzNS43NTUgMjEuNDI2IDc5Ljg2MSAyMS40MjZzNzkuODYxLTkuNTkyMyA3OS44NjEtMjEuNDI2YzAtNi4wMTg1LTkuMjUxMi0xMS40NTktMjQuMTQ4LTE1LjM1MSAwLjAzNzUtMC4wMjg3IDAuMDc1LTAuMDYxNSAwLjExMjUtMC4wOTAyIDE1LjAyMSAzLjkxMDEgMjQuMzU2IDkuMzgyOCAyNC4zNTYgMTUuNDQxIDAgMTEuODgxLTM1Ljg5OSAyMS41MTMtODAuMTgyIDIxLjUxMyIgZmlsbD0iI2ZlZmVmZiIvPgogICA8cGF0aCBpZD0icGF0aDU2MzMiIGQ9Im0zMTkuODkgOTIxLjUxYy00NC4xMDYgMC03OS44NjEtOS41OTIzLTc5Ljg2MS0yMS40MjYgMC02LjAxODUgOS4yNTEyLTExLjQ1OSAyNC4xNDgtMTUuMzUxIDAuMDM4NyAwLjAzMTIgMC4wNzI1IDAuMDYwMSAwLjExMTI1IDAuMDkwOS0xNC43NyAzLjg3NTUtMjMuOTM5IDkuMjgwMi0yMy45MzkgMTUuMjYgMCAxMS43ODYgMzUuNjEyIDIxLjM0IDc5LjU0MSAyMS4zNHM3OS41NC05LjU1MzcgNzkuNTQtMjEuMzRjMC01Ljk4LTkuMTY3NS0xMS4zODUtMjMuOTM4LTE1LjI2IDAuMDM4Ny0wLjAzMDggMC4wNzI1LTAuMDU5NiAwLjExMTI1LTAuMDkwOSAxNC44OTYgMy44OTIxIDI0LjE0OCA5LjMzMjYgMjQuMTQ4IDE1LjM1MSAwIDExLjgzNC0zNS43NTUgMjEuNDI2LTc5Ljg2MSAyMS40MjYiIGZpbGw9IiNmZWZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjM1IiBkPSJtMzE5Ljg5IDkyMS40MmMtNDMuOTI5IDAtNzkuNTQxLTkuNTUzNy03OS41NDEtMjEuMzQgMC01Ljk4IDkuMTY4OC0xMS4zODUgMjMuOTM5LTE1LjI2IDAuMDM3NSAwLjAzMDMgMC4wNzYyIDAuMDYxNSAwLjExMzc1IDAuMDkxMy0xNC42NDYgMy44NTY0LTIzLjczMSA5LjIyNzYtMjMuNzMxIDE1LjE2OSAwIDExLjczOSAzNS40NjggMjEuMjU1IDc5LjIyIDIxLjI1NSA0My43NTEgMCA3OS4yMi05LjUxNjEgNzkuMjItMjEuMjU1IDAtNS45NDE0LTkuMDg1LTExLjMxMi0yMy43My0xNS4xNjkgMC4wMzYzLTAuMDI5OCAwLjA3NS0wLjA2MSAwLjExMjUtMC4wOTEzIDE0Ljc3IDMuODc1NSAyMy45MzggOS4yODAyIDIzLjkzOCAxNS4yNiAwIDExLjc4Ni0zNS42MTEgMjEuMzQtNzkuNTQgMjEuMzQiIGZpbGw9IiNmZWZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjM3IiBkPSJtMzE5Ljg5IDkyMS4zM2MtNDMuNzUyIDAtNzkuMjItOS41MTYxLTc5LjIyLTIxLjI1NSAwLTUuOTQxNCA5LjA4NS0xMS4zMTIgMjMuNzMxLTE1LjE2OSAwLjAzODcgMC4wMzEyIDAuMDczOCAwLjA1OTEgMC4xMTI1IDAuMDkwNC0xNC41MjEgMy44Mzk5LTIzLjUyMiA5LjE3NzItMjMuNTIyIDE1LjA3OSAwIDExLjY5MSAzNS4zMjQgMjEuMTY5IDc4Ljg5OSAyMS4xNjlzNzguODk5LTkuNDc3NiA3OC44OTktMjEuMTY5YzAtNS45MDE0LTkuMDAxMi0xMS4yMzktMjMuNTIyLTE1LjA3OCAwLjAzODctMC4wMzEyIDAuMDc1LTAuMDYwMSAwLjExMzc1LTAuMDkxNCAxNC42NDUgMy44NTY1IDIzLjczIDkuMjI3NiAyMy43MyAxNS4xNjkgMCAxMS43MzktMzUuNDY5IDIxLjI1NS03OS4yMiAyMS4yNTUiIGZpbGw9IiNmZGZlZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjM5IiBkPSJtMzE5Ljg5IDkyMS4yNWMtNDMuNTc1IDAtNzguODk5LTkuNDc3Ni03OC44OTktMjEuMTY5IDAtNS45MDE0IDkuMDAxMi0xMS4yMzkgMjMuNTIyLTE1LjA3OSAwLjAzNjIgMC4wMjk3IDAuMDc2MiAwLjA2MjUgMC4xMTI1IDAuMDkxNC0xNC4zOTQgMy44MjA4LTIzLjMxNSA5LjEyNS0yMy4zMTUgMTQuOTg3IDAgMTEuNjQ0IDM1LjE4MSAyMS4wODMgNzguNTc5IDIxLjA4M3M3OC41NzktOS40MzkgNzguNTc5LTIxLjA4M2MwLTUuODYyMi04LjkyMTItMTEuMTY2LTIzLjMxNS0xNC45ODcgMC4wMzYyLTAuMDI4OSAwLjA3NjMtMC4wNjE2IDAuMTEyNS0wLjA5MDQgMTQuNTIxIDMuODM4OSAyMy41MjIgOS4xNzYyIDIzLjUyMiAxNS4wNzggMCAxMS42OTEtMzUuMzI0IDIxLjE2OS03OC44OTkgMjEuMTY5IiBmaWxsPSIjZmRmZWZlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTY0MSIgZD0ibTMxOS44OSA5MjEuMTZjLTQzLjM5OCAwLTc4LjU3OS05LjQzOS03OC41NzktMjEuMDgzIDAtNS44NjIyIDguOTIxMi0xMS4xNjYgMjMuMzE1LTE0Ljk4NyAwLjA0IDAuMDMxMiAwLjA3NSAwLjA2IDAuMTEzNzUgMC4wOTA3LTE0LjI3MSAzLjgwNDItMjMuMTA4IDkuMDcyOC0yMy4xMDggMTQuODk2IDAgMTEuNTk2IDM1LjAzNiAyMC45OTYgNzguMjU4IDIwLjk5NiA0My4yMiAwIDc4LjI1OC05LjM5OTkgNzguMjU4LTIwLjk5NiAwLTUuODIzOC04LjgzNjItMTEuMDkyLTIzLjEwOC0xNC44OTYgMC4wMzg3LTAuMDMwOCAwLjA3NS0wLjA1OTUgMC4xMTM3NS0wLjA5MDcgMTQuMzk0IDMuODIwOCAyMy4zMTUgOS4xMjUgMjMuMzE1IDE0Ljk4NyAwIDExLjY0NC0zNS4xODEgMjEuMDgzLTc4LjU3OSAyMS4wODMiIGZpbGw9IiNmZGZkZmUiLz4KICAgPHBhdGggaWQ9InBhdGg1NjQzIiBkPSJtMzE5Ljg5IDkyMS4wOGMtNDMuMjIxIDAtNzguMjU4LTkuMzk5OS03OC4yNTgtMjAuOTk2IDAtNS44MjM4IDguODM2Mi0xMS4wOTIgMjMuMTA4LTE0Ljg5NiAwLjAzNzUgMC4wMjg5IDAuMDc3NSAwLjA2MTUgMC4xMTUgMC4wOTE0LTE0LjE0NiAzLjc4NTEtMjIuOTAxIDkuMDIxNC0yMi45MDEgMTQuODA1IDAgMTEuNTQ5IDM0Ljg5MiAyMC45MSA3Ny45MzYgMjAuOTEgNDMuMDQyIDAgNzcuOTM2LTkuMzYxMyA3Ny45MzYtMjAuOTEgMC01Ljc4MzgtOC43NTUtMTEuMDItMjIuOTAxLTE0LjgwNSAwLjAzNzUtMC4wMjk5IDAuMDc3NS0wLjA2MjUgMC4xMTUtMC4wOTE0IDE0LjI3MSAzLjgwNDIgMjMuMTA4IDkuMDcyOCAyMy4xMDggMTQuODk2IDAgMTEuNTk2LTM1LjAzOCAyMC45OTYtNzguMjU4IDIwLjk5NiIgZmlsbD0iI2ZkZmRmZCIvPgogICA8cGF0aCBpZD0icGF0aDU2NDUiIGQ9Im0zMTkuODkgOTIwLjk5Yy00My4wNDQgMC03Ny45MzYtOS4zNjEzLTc3LjkzNi0yMC45MSAwLTUuNzgzOCA4Ljc1NS0xMS4wMiAyMi45MDEtMTQuODA1IDAuMDM4NyAwLjAzMTIgMC4wNzUgMC4wNiAwLjExMzc1IDAuMDkwMi0xNC4wMjQgMy43Njg2LTIyLjY5NSA4Ljk2OTctMjIuNjk1IDE0LjcxNSAwIDExLjUwMSAzNC43NSAyMC44MjQgNzcuNjE2IDIwLjgyNHM3Ny42MTYtOS4zMjIzIDc3LjYxNi0yMC44MjRjMC01Ljc0NTEtOC42Ny0xMC45NDYtMjIuNjk1LTE0LjcxNSAwLjAzODctMC4wMzAzIDAuMDc1LTAuMDU5IDAuMTEzNzUtMC4wOTAyIDE0LjE0NiAzLjc4NTEgMjIuOTAxIDkuMDIxNCAyMi45MDEgMTQuODA1IDAgMTEuNTQ5LTM0Ljg5NCAyMC45MS03Ny45MzYgMjAuOTEiIGZpbGw9IiNmZGZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1NjQ3IiBkPSJtMzE5Ljg5IDkyMC45Yy00Mi44NjYgMC03Ny42MTYtOS4zMjIzLTc3LjYxNi0yMC44MjQgMC01Ljc0NTEgOC42NzEyLTEwLjk0NiAyMi42OTUtMTQuNzE1IDAuMDM3NSAwLjAyOTkgMC4wNzg4IDAuMDYyNSAwLjExNSAwLjA5MTQtMTMuOSAzLjc1LTIyLjQ4OSA4LjkxNzUtMjIuNDg5IDE0LjYyNCAwIDExLjQ1NCAzNC42MDYgMjAuNzM3IDc3LjI5NSAyMC43MzdzNzcuMjk1LTkuMjgzNyA3Ny4yOTUtMjAuNzM3YzAtNS43MDYtOC41ODg4LTEwLjg3NC0yMi40ODktMTQuNjI0IDAuMDM3NS0wLjAyODkgMC4wNzc1LTAuMDYxNSAwLjExNS0wLjA5MTQgMTQuMDI1IDMuNzY4NiAyMi42OTUgOC45Njk3IDIyLjY5NSAxNC43MTUgMCAxMS41MDEtMzQuNzUgMjAuODI0LTc3LjYxNiAyMC44MjQiIGZpbGw9IiNmY2ZkZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1NjQ5IiBkPSJtMzE5Ljg5IDkyMC44MmMtNDIuNjg5IDAtNzcuMjk1LTkuMjgzNy03Ny4yOTUtMjAuNzM3IDAtNS43MDYgOC41ODg4LTEwLjg3NCAyMi40ODktMTQuNjI0IDAuMDM4NyAwLjAzMTIgMC4wNzYzIDAuMDU5NSAwLjExNSAwLjA5MDctMTMuNzc2IDMuNzMxNS0yMi4yODQgOC44NjUyLTIyLjI4NCAxNC41MzMgMCAxMS40MDYgMzQuNDYyIDIwLjY1MiA3Ni45NzUgMjAuNjUyIDQyLjUxMSAwIDc2Ljk3NC05LjI0NjEgNzYuOTc0LTIwLjY1MiAwLTUuNjY3NS04LjUwNjItMTAuODAxLTIyLjI4Mi0xNC41MzMgMC4wMzg3LTAuMDMxMiAwLjA3NjItMC4wNTk1IDAuMTE1LTAuMDkwNyAxMy45IDMuNzUgMjIuNDg5IDguOTE3NSAyMi40ODkgMTQuNjI0IDAgMTEuNDU0LTM0LjYwNiAyMC43MzctNzcuMjk1IDIwLjczNyIgZmlsbD0iI2ZjZmRmZCIvPgogICA8cGF0aCBpZD0icGF0aDU2NTEiIGQ9Im0zMTkuODkgOTIwLjczYy00Mi41MTIgMC03Ni45NzUtOS4yNDYxLTc2Ljk3NS0yMC42NTIgMC01LjY2NzUgOC41MDc1LTEwLjgwMSAyMi4yODQtMTQuNTMzIDAuMDQgMC4wMzEyIDAuMDc3NSAwLjA2MDEgMC4xMTYyNSAwLjA5MTQtMTMuNjUxIDMuNzEzOS0yMi4wNzkgOC44MTM5LTIyLjA3OSAxNC40NDEgMCAxMS4zNTkgMzQuMzE5IDIwLjU2NiA3Ni42NTQgMjAuNTY2czc2LjY1NC05LjIwNzUgNzYuNjU0LTIwLjU2NmMwLTUuNjI3NS04LjQyNzUtMTAuNzI4LTIyLjA3OS0xNC40NDEgMC4wNC0wLjAzMTIgMC4wNzYzLTAuMDYwMSAwLjExNjI1LTAuMDkxNCAxMy43NzYgMy43MzE1IDIyLjI4MiA4Ljg2NTIgMjIuMjgyIDE0LjUzMyAwIDExLjQwNi0zNC40NjIgMjAuNjUyLTc2Ljk3NCAyMC42NTIiIGZpbGw9IiNmY2ZjZmQiLz4KICAgPHBhdGggaWQ9InBhdGg1NjUzIiBkPSJtMzE5Ljg5IDkyMC42NWMtNDIuMzM1IDAtNzYuNjU0LTkuMjA3NS03Ni42NTQtMjAuNTY2IDAtNS42Mjc1IDguNDI3NS0xMC43MjggMjIuMDc5LTE0LjQ0MSAwLjAzNzUgMC4wMjg3IDAuMDc4OCAwLjA2MTUgMC4xMTYyNSAwLjA5MTItMTMuNTI5IDMuNjk1NC0yMS44NzQgOC43NjEzLTIxLjg3NCAxNC4zNSAwIDExLjMxMSAzNC4xNzUgMjAuNDggNzYuMzMyIDIwLjQ4IDQyLjE1OCAwIDc2LjMzMi05LjE2ODkgNzYuMzMyLTIwLjQ4IDAtNS41ODg5LTguMzQzOC0xMC42NTUtMjEuODc0LTE0LjM1IDAuMDQtMC4wMzEyIDAuMDc3NS0wLjA2IDAuMTE2MjUtMC4wOTEyIDEzLjY1MSAzLjcxMzkgMjIuMDc5IDguODEzOSAyMi4wNzkgMTQuNDQxIDAgMTEuMzU5LTM0LjMxOSAyMC41NjYtNzYuNjU0IDIwLjU2NiIgZmlsbD0iI2ZjZmNmYyIvPgogICA8cGF0aCBpZD0icGF0aDU2NTUiIGQ9Im0zMTkuODkgOTIwLjU2Yy00Mi4xNTggMC03Ni4zMzItOS4xNjg5LTc2LjMzMi0yMC40OCAwLTUuNTg4OSA4LjM0NS0xMC42NTUgMjEuODc0LTE0LjM1IDAuMDM4OCAwLjAzMDMgMC4wNzc1IDAuMDYwMSAwLjExNjI1IDAuMDkwNC0xMy40MDUgMy42NzcyLTIxLjY3IDguNzEtMjEuNjcgMTQuMjYgMCAxMS4yNjQgMzQuMDMyIDIwLjM5NCA3Ni4wMTIgMjAuMzk0czc2LjAxMi05LjEyOTkgNzYuMDEyLTIwLjM5NGMwLTUuNTQ5OC04LjI2NS0xMC41ODItMjEuNjctMTQuMjU5IDAuMDM3NS0wLjAyOTkgMC4wOC0wLjA2MjUgMC4xMTYyNS0wLjA5MTQgMTMuNTMgMy42OTU0IDIxLjg3NCA4Ljc2MTIgMjEuODc0IDE0LjM1IDAgMTEuMzExLTM0LjE3NSAyMC40OC03Ni4zMzIgMjAuNDgiIGZpbGw9IiNmYmZjZmMiLz4KICAgPHBhdGggaWQ9InBhdGg1NjU3IiBkPSJtMzE5Ljg5IDkyMC40N2MtNDEuOTggMC03Ni4wMTItOS4xMjk5LTc2LjAxMi0yMC4zOTQgMC01LjU0OTggOC4yNjUtMTAuNTgyIDIxLjY3LTE0LjI2IDAuMDM4NyAwLjAzMDggMC4wNzc1IDAuMDYxIDAuMTE3NSAwLjA5MDgtMTMuMjg0IDMuNjU5Mi0yMS40NjYgOC42NTc4LTIxLjQ2NiAxNC4xNjkgMCAxMS4yMTYgMzMuODg4IDIwLjMwOCA3NS42OTEgMjAuMzA4IDQxLjgwNCAwIDc1LjY5MS05LjA5MTQgNzUuNjkxLTIwLjMwOCAwLTUuNTExMi04LjE4MTItMTAuNTA5LTIxLjQ2Ni0xNC4xNjkgMC4wNC0wLjAyOTggMC4wNzg3LTAuMDYgMC4xMTc1LTAuMDg5OCAxMy40MDUgMy42NzYyIDIxLjY3IDguNzA5IDIxLjY3IDE0LjI1OSAwIDExLjI2NC0zNC4wMzIgMjAuMzk0LTc2LjAxMiAyMC4zOTQiIGZpbGw9IiNmYmZjZmMiLz4KICAgPHBhdGggaWQ9InBhdGg1NjU5IiBkPSJtMzE5Ljg5IDkyMC4zOWMtNDEuODA0IDAtNzUuNjkxLTkuMDkxNC03NS42OTEtMjAuMzA4IDAtNS41MTEyIDguMTgyNS0xMC41MSAyMS40NjYtMTQuMTY5IDAuMDM4NyAwLjAzMTIgMC4wNzc1IDAuMDYxNiAwLjExNzUgMC4wOTE0LTEzLjE2MSAzLjY0MTEtMjEuMjYyIDguNjA1LTIxLjI2MiAxNC4wNzggMCAxMS4xNjkgMzMuNzQ0IDIwLjIyMSA3NS4zNyAyMC4yMjFzNzUuMzctOS4wNTIyIDc1LjM3LTIwLjIyMWMwLTUuNDcyNi04LjEwMTItMTAuNDM2LTIxLjI2MS0xNC4wNzggMC4wMzg3LTAuMDI5OCAwLjA3NzUtMC4wNjAxIDAuMTE2MjUtMC4wOTE0IDEzLjI4NSAzLjY2MDIgMjEuNDY2IDguNjU3OCAyMS40NjYgMTQuMTY5IDAgMTEuMjE2LTMzLjg4OCAyMC4zMDgtNzUuNjkxIDIwLjMwOCIgZmlsbD0iI2ZiZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDU2NjEiIGQ9Im0zMTkuODkgOTIwLjNjLTQxLjYyNiAwLTc1LjM3LTkuMDUyMi03NS4zNy0yMC4yMjEgMC01LjQ3MjYgOC4xMDEyLTEwLjQzNiAyMS4yNjItMTQuMDc4IDAuMDM4NyAwLjAzMTIgMC4wNzc1IDAuMDYxNSAwLjExNzUgMC4wOTEzLTEzLjAzOSAzLjYyMjYtMjEuMDYgOC41NTI4LTIxLjA2IDEzLjk4NiAwIDExLjEyIDMzLjYwMSAyMC4xMzYgNzUuMDUgMjAuMTM2czc1LjA1LTkuMDE2MSA3NS4wNS0yMC4xMzZjMC01LjQzMzYtOC4wMjEyLTEwLjM2NC0yMS4wNi0xMy45ODYgMC4wNC0wLjAyOTcgMC4wNzg4LTAuMDYgMC4xMTg3NS0wLjA5MTMgMTMuMTYgMy42NDExIDIxLjI2MSA4LjYwNSAyMS4yNjEgMTQuMDc4IDAgMTEuMTY5LTMzLjc0NCAyMC4yMjEtNzUuMzcgMjAuMjIxIiBmaWxsPSIjZmJmYmZiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTY2MyIgZD0ibTMxOS44OSA5MjAuMjJjLTQxLjQ0OSAwLTc1LjA1LTkuMDE2MS03NS4wNS0yMC4xMzYgMC01LjQzMzYgOC4wMjEyLTEwLjM2NCAyMS4wNi0xMy45ODYgMC4wMzg4IDAuMDMwNCAwLjA3ODggMC4wNjE2IDAuMTE3NSAwLjA5MTQtMTIuOTE2IDMuNjA0LTIwLjg1NiA4LjUtMjAuODU2IDEzLjg5NSAwIDExLjA3MiAzMy40NTYgMjAuMDUgNzQuNzI5IDIwLjA1IDQxLjI3MSAwIDc0LjcyOS04Ljk3NzYgNzQuNzI5LTIwLjA1IDAtNS4zOTUtNy45NC0xMC4yOTEtMjAuODU2LTEzLjg5NSAwLjAzODctMC4wMjk4IDAuMDc4Ny0wLjA2MDEgMC4xMTc1LTAuMDkxNCAxMy4wMzkgMy42MjI2IDIxLjA2IDguNTUyOCAyMS4wNiAxMy45ODYgMCAxMS4xMi0zMy42MDEgMjAuMTM2LTc1LjA1IDIwLjEzNiIgZmlsbD0iI2ZhZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDU2NjUiIGQ9Im0zMTkuODkgOTIwLjEzYy00MS4yNzIgMC03NC43MjktOC45Nzc2LTc0LjcyOS0yMC4wNSAwLTUuMzk1IDcuOTQtMTAuMjkxIDIwLjg1Ni0xMy44OTUgMC4wNCAwLjAyOTcgMC4wOCAwLjA2MTUgMC4xMTg3NSAwLjA5MTItMTIuNzk1IDMuNTg1LTIwLjY1NSA4LjQ0NzItMjAuNjU1IDEzLjgwNCAwIDExLjAyNSAzMy4zMTQgMTkuOTY0IDc0LjQwOSAxOS45NjRzNzQuNDA5LTguOTM5IDc0LjQwOS0xOS45NjRjMC01LjM1NS03Ljg2MjUtMTAuMjE5LTIwLjY1NS0xMy44MDQgMC4wMzg3LTAuMDI5OCAwLjA3ODctMC4wNjE1IDAuMTE4NzUtMC4wOTEyIDEyLjkxNiAzLjYwNCAyMC44NTYgOC41IDIwLjg1NiAxMy44OTUgMCAxMS4wNzItMzMuNDU4IDIwLjA1LTc0LjcyOSAyMC4wNSIgZmlsbD0iI2ZhZmJmYiIvPgogICA8cGF0aCBpZD0icGF0aDU2NjciIGQ9Im0zMTkuODkgOTIwLjA0Yy00MS4wOTUgMC03NC40MDktOC45MzktNzQuNDA5LTE5Ljk2NCAwLTUuMzU2NSA3Ljg2LTEwLjIxOSAyMC42NTUtMTMuODA0IDAuMDQgMC4wMjk5IDAuMDggMC4wNjExIDAuMTIgMC4wOTE0LTEyLjY3NCAzLjU2NTgtMjAuNDU0IDguMzk2LTIwLjQ1NCAxMy43MTIgMCAxMC45NzggMzMuMTcgMTkuODc3IDc0LjA4OCAxOS44NzdzNzQuMDg4LTguODk5OSA3NC4wODgtMTkuODc3YzAtNS4zMTY0LTcuNzgtMTAuMTQ2LTIwLjQ1Mi0xMy43MTIgMC4wMzg3LTAuMDMwMiAwLjA3ODgtMC4wNjE1IDAuMTE4NzUtMC4wOTE0IDEyLjc5MiAzLjU4NSAyMC42NTUgOC40NDg4IDIwLjY1NSAxMy44MDQgMCAxMS4wMjUtMzMuMzE0IDE5Ljk2NC03NC40MDkgMTkuOTY0IiBmaWxsPSIjZmFmYWZhIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTY2OSIgZD0ibTMxOS44OSA5MTkuOTZjLTQwLjkxOCAwLTc0LjA4OC04Ljg5OTktNzQuMDg4LTE5Ljg3NyAwLTUuMzE2NCA3Ljc4LTEwLjE0NiAyMC40NTQtMTMuNzEyIDAuMDM4NyAwLjAyOTcgMC4wOCAwLjA2MSAwLjExODc1IDAuMDkxMy0xMi41NSAzLjU0NzQtMjAuMjUxIDguMzQzOC0yMC4yNTEgMTMuNjIxIDAgMTAuOTMgMzMuMDI2IDE5Ljc5MSA3My43NjYgMTkuNzkxczczLjc2Ni04Ljg2MDkgNzMuNzY2LTE5Ljc5MWMwLTUuMjc3NC03LjcwMTItMTAuMDc0LTIwLjI1MS0xMy42MjEgMC4wNC0wLjAzMDMgMC4wOC0wLjA2MTUgMC4xMi0wLjA5MTMgMTIuNjcyIDMuNTY1OSAyMC40NTIgOC4zOTYgMjAuNDUyIDEzLjcxMiAwIDEwLjk3OC0zMy4xNyAxOS44NzctNzQuMDg4IDE5Ljg3NyIgZmlsbD0iI2ZhZmFmYSIvPgogICA8cGF0aCBpZD0icGF0aDU2NzEiIGQ9Im0zMTkuODkgOTE5Ljg3Yy00MC43NCAwLTczLjc2Ni04Ljg2MDktNzMuNzY2LTE5Ljc5MSAwLTUuMjc3NCA3LjcwMTItMTAuMDc0IDIwLjI1MS0xMy42MjEgMC4wNCAwLjAyOTkgMC4wOCAwLjA1OTYgMC4xMiAwLjA5MTQtMTIuNDMxIDMuNTI4Mi0yMC4wNTEgOC4yOTEtMjAuMDUxIDEzLjUzIDAgMTAuODgyIDMyLjg4MiAxOS43MDUgNzMuNDQ2IDE5LjcwNSA0MC41NjIgMCA3My40NDYtOC44MjI3IDczLjQ0Ni0xOS43MDUgMC01LjIzODgtNy42Mi0xMC4wMDItMjAuMDUxLTEzLjUzIDAuMDQtMC4wMzAzIDAuMDgtMC4wNjE1IDAuMTItMC4wOTE0IDEyLjU1IDMuNTQ3NCAyMC4yNTEgOC4zNDM4IDIwLjI1MSAxMy42MjEgMCAxMC45My0zMy4wMjYgMTkuNzkxLTczLjc2NiAxOS43OTEiIGZpbGw9IiNmOWZhZmEiLz4KICAgPHBhdGggaWQ9InBhdGg1NjczIiBkPSJtMzE5Ljg5IDkxOS43OWMtNDAuNTY0IDAtNzMuNDQ2LTguODIyNy03My40NDYtMTkuNzA1IDAtNS4yMzg4IDcuNjItMTAuMDAyIDIwLjA1MS0xMy41MyAwLjA0IDAuMDI5OCAwLjA4MTMgMC4wNjEgMC4xMjEyNSAwLjA5MDctMTIuMzExIDMuNTEwMi0xOS44NTEgOC4yMzg4LTE5Ljg1MSAxMy40MzkgMCAxMC44MzUgMzIuNzM5IDE5LjYxOSA3My4xMjUgMTkuNjE5czczLjEyNS04Ljc4MzYgNzMuMTI1LTE5LjYxOWMwLTUuMjAwMi03LjU0LTkuOTI3Mi0xOS44NS0xMy40MzkgMC4wMzg3LTAuMDI5NyAwLjA4LTAuMDYxIDAuMTItMC4wOTA3IDEyLjQzMSAzLjUyODIgMjAuMDUxIDguMjkxIDIwLjA1MSAxMy41MyAwIDEwLjg4Mi0zMi44ODQgMTkuNzA1LTczLjQ0NiAxOS43MDUiIGZpbGw9IiNmOWY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg1Njc1IiBkPSJtMzE5Ljg5IDkxOS43Yy00MC4zODYgMC03My4xMjUtOC43ODM2LTczLjEyNS0xOS42MTkgMC01LjIwMDIgNy41NC05LjkyODggMTkuODUxLTEzLjQzOSAwLjAzODcgMC4wMzAzIDAuMDggMC4wNjAxIDAuMTIgMC4wOTE0LTEyLjE4OCAzLjQ5MTEtMTkuNjUxIDguMTg2NS0xOS42NTEgMTMuMzQ4IDAgMTAuNzg4IDMyLjU5NiAxOS41MzQgNzIuODA1IDE5LjUzNHM3Mi44MDQtOC43NDYxIDcyLjgwNC0xOS41MzRjMC01LjE2MTEtNy40NjI1LTkuODU2NS0xOS42NS0xMy4zNDggMC4wNC0wLjAyOTkgMC4wODEyLTAuMDYxMSAwLjEyMTI1LTAuMDkxNCAxMi4zMSAzLjUxMTggMTkuODUgOC4yMzg4IDE5Ljg1IDEzLjQzOSAwIDEwLjgzNS0zMi43MzkgMTkuNjE5LTczLjEyNSAxOS42MTkiIGZpbGw9IiNmOWY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg1Njc3IiBkPSJtMzE5Ljg5IDkxOS42MWMtNDAuMjA5IDAtNzIuODA1LTguNzQ2MS03Mi44MDUtMTkuNTM0IDAtNS4xNjExIDcuNDYzOC05Ljg1NjUgMTkuNjUxLTEzLjM0OCAwLjA0MjUgMC4wMzEyIDAuMDggMC4wNTkgMC4xMjEyNSAwLjA5MTItMTIuMDY4IDMuNDcyNi0xOS40NTEgOC4xMzM5LTE5LjQ1MSAxMy4yNTYgMCAxMC43NCAzMi40NTIgMTkuNDQ4IDcyLjQ4NCAxOS40NDggNDAuMDMxIDAgNzIuNDg0LTguNzA3NSA3Mi40ODQtMTkuNDQ4IDAtNS4xMjI1LTcuMzgzOC05Ljc4MzgtMTkuNDUxLTEzLjI1NiAwLjA0MjUtMC4wMzIyIDAuMDc4Ny0wLjA2IDAuMTIxMjUtMC4wOTEyIDEyLjE4OCAzLjQ5MTEgMTkuNjUgOC4xODY1IDE5LjY1IDEzLjM0OCAwIDEwLjc4OC0zMi41OTUgMTkuNTM0LTcyLjgwNCAxOS41MzQiIGZpbGw9IiNmOGY5ZjkiLz4KICAgPHBhdGggaWQ9InBhdGg1Njc5IiBkPSJtMzE5Ljg5IDkxOS41M2MtNDAuMDMxIDAtNzIuNDg0LTguNzA3NS03Mi40ODQtMTkuNDQ4IDAtNS4xMjI1IDcuMzgzOC05Ljc4MzggMTkuNDUxLTEzLjI1NiAwLjA0IDAuMDI4OSAwLjA4MjUgMC4wNjE1IDAuMTIyNSAwLjA5MTQtMTEuOTQ5IDMuNDUzNi0xOS4yNTIgOC4wOC0xOS4yNTIgMTMuMTY1IDAgMTAuNjkyIDMyLjMwOCAxOS4zNjEgNzIuMTYyIDE5LjM2MSAzOS44NTUgMCA3Mi4xNjItOC42NjkgNzIuMTYyLTE5LjM2MSAwLTUuMDg1LTcuMzAzOC05LjcxMTQtMTkuMjUyLTEzLjE2NSAwLjA0LTAuMDI5OSAwLjA4MjUtMC4wNjExIDAuMTIyNS0wLjA5MTQgMTIuMDY4IDMuNDcyNiAxOS40NTEgOC4xMzM5IDE5LjQ1MSAxMy4yNTYgMCAxMC43NC0zMi40NTIgMTkuNDQ4LTcyLjQ4NCAxOS40NDgiIGZpbGw9IiNmOGY4ZjgiLz4KICAgPHBhdGggaWQ9InBhdGg1NjgxIiBkPSJtMzE5Ljg5IDkxOS40NGMtMzkuODU1IDAtNzIuMTYyLTguNjY5LTcyLjE2Mi0xOS4zNjEgMC01LjA4NSA3LjMwMzgtOS43MTE0IDE5LjI1Mi0xMy4xNjUgMC4wNCAwLjAyODggMC4wODEyIDAuMDYxNSAwLjEyMTI1IDAuMDkxMy0xMS44MjggMy40MzUxLTE5LjA1NCA4LjAyNzQtMTkuMDU0IDEzLjA3NCAwIDEwLjY0NSAzMi4xNjUgMTkuMjc1IDcxLjg0MiAxOS4yNzUgMzkuNjc4IDAgNzEuODQyLTguNjI5OSA3MS44NDItMTkuMjc1IDAtNS4wNDY0LTcuMjI2Mi05LjYzODYtMTkuMDU0LTEzLjA3NCAwLjA0LTAuMDI5OCAwLjA4MTMtMC4wNjI1IDAuMTIxMjUtMC4wOTEzIDExLjk0OSAzLjQ1MzYgMTkuMjUyIDguMDggMTkuMjUyIDEzLjE2NSAwIDEwLjY5Mi0zMi4zMDggMTkuMzYxLTcyLjE2MiAxOS4zNjEiIGZpbGw9IiNmOGY4ZjgiLz4KICAgPHBhdGggaWQ9InBhdGg1NjgzIiBkPSJtMzE5Ljg5IDkxOS4zNWMtMzkuNjc4IDAtNzEuODQyLTguNjI5OS03MS44NDItMTkuMjc1IDAtNS4wNDY0IDcuMjI2Mi05LjYzODYgMTkuMDU0LTEzLjA3NCAwLjA0MjUgMC4wMzEyIDAuMDgxMyAwLjA1ODYgMC4xMjM3NSAwLjA5MTQtMTEuNzA5IDMuNDE2LTE4Ljg1NiA3Ljk3NS0xOC44NTYgMTIuOTgyIDAgMTAuNTk4IDMyLjAyMSAxOS4xODkgNzEuNTIxIDE5LjE4OXM3MS41MjEtOC41OTEzIDcxLjUyMS0xOS4xODljMC01LjAwNzQtNy4xNDc1LTkuNTY2NC0xOC44NTYtMTIuOTgyIDAuMDQyNS0wLjAzMTIgMC4wODEzLTAuMDYwMSAwLjEyMzc1LTAuMDkxNCAxMS44MjggMy40MzUxIDE5LjA1NCA4LjAyNzQgMTkuMDU0IDEzLjA3NCAwIDEwLjY0NS0zMi4xNjUgMTkuMjc1LTcxLjg0MiAxOS4yNzUiIGZpbGw9IiNmN2Y4ZjgiLz4KICAgPHBhdGggaWQ9InBhdGg1Njg1IiBkPSJtMzE5Ljg5IDkxOS4yN2MtMzkuNSAwLTcxLjUyMS04LjU5MTMtNzEuNTIxLTE5LjE4OSAwLTUuMDA3NCA3LjE0NzUtOS41NjY0IDE4Ljg1Ni0xMi45ODIgMC4wNCAwLjAyODggMC4wODI1IDAuMDYxIDAuMTIyNSAwLjA5MTMtMTEuNTg4IDMuMzk3NS0xOC42NTkgNy45MjI0LTE4LjY1OSAxMi44OTEgMCAxMC41NSAzMS44NzkgMTkuMTAzIDcxLjIwMSAxOS4xMDNzNzEuMi04LjU1MjggNzEuMi0xOS4xMDNjMC00Ljk2ODgtNy4wNjg4LTkuNDkzNi0xOC42NTgtMTIuODkxIDAuMDQtMC4wMzAzIDAuMDgyNS0wLjA2MjUgMC4xMjI1LTAuMDkxMyAxMS43MDkgMy40MTYgMTguODU2IDcuOTc1IDE4Ljg1NiAxMi45ODIgMCAxMC41OTgtMzIuMDIxIDE5LjE4OS03MS41MjEgMTkuMTg5IiBmaWxsPSIjZjdmN2Y3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTY4NyIgZD0ibTMxOS44OSA5MTkuMThjLTM5LjMyMiAwLTcxLjIwMS04LjU1MjgtNzEuMjAxLTE5LjEwMyAwLTQuOTY4OCA3LjA3MTItOS40OTM2IDE4LjY1OS0xMi44OTEgMC4wNDI1IDAuMDMxMiAwLjA4MTIgMC4wNjAxIDAuMTIzNzUgMC4wOTE0LTExLjQ3IDMuMzc4NC0xOC40NjEgNy44Njk2LTE4LjQ2MSAxMi44IDAgMTAuNTAyIDMxLjczNCAxOS4wMTYgNzAuODggMTkuMDE2czcwLjg4LTguNTEzNiA3MC44OC0xOS4wMTZjMC00LjkzMDEtNi45OTEyLTkuNDIxNC0xOC40NjEtMTIuOCAwLjA0MjUtMC4wMzEyIDAuMDgxMy0wLjA2MDEgMC4xMjM3NS0wLjA5MTQgMTEuNTg5IDMuMzk3NSAxOC42NTggNy45MjI0IDE4LjY1OCAxMi44OTEgMCAxMC41NS0zMS44NzggMTkuMTAzLTcxLjIgMTkuMTAzIiBmaWxsPSIjZjZmN2Y3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTY4OSIgZD0ibTMxOS44OSA5MTkuMWMtMzkuMTQ2IDAtNzAuODgtOC41MTM2LTcwLjg4LTE5LjAxNiAwLTQuOTMwMSA2Ljk5MTItOS40MjE0IDE4LjQ2MS0xMi44IDAuMDQgMC4wMjgyIDAuMDgzOCAwLjA2MSAwLjEyMzc1IDAuMDkwNy0xMS4zNDkgMy4zNTg5LTE4LjI2NCA3LjgxNzktMTguMjY0IDEyLjcwOSAwIDEwLjQ1NSAzMS41OSAxOC45MzEgNzAuNTU5IDE4LjkzMXM3MC41NTktOC40NzYgNzAuNTU5LTE4LjkzMWMwLTQuODkxMS02LjkxNS05LjM1MDEtMTguMjY0LTEyLjcwOSAwLjA0LTAuMDI5NyAwLjA4MzgtMC4wNjI1IDAuMTIzNzUtMC4wOTA3IDExLjQ3IDMuMzc4NCAxOC40NjEgNy44Njk2IDE4LjQ2MSAxMi44IDAgMTAuNTAyLTMxLjczNCAxOS4wMTYtNzAuODggMTkuMDE2IiBmaWxsPSIjZjZmNmY3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTY5MSIgZD0ibTMxOS44OSA5MTkuMDFjLTM4Ljk2OSAwLTcwLjU1OS04LjQ3Ni03MC41NTktMTguOTMxIDAtNC44OTExIDYuOTE1LTkuMzUwMSAxOC4yNjQtMTIuNzA5IDAuMDQyNSAwLjAzMTIgMC4wODI1IDAuMDYwMSAwLjEyNSAwLjA5MTQtMTEuMjMxIDMuMzQwMi0xOC4wNjkgNy43NjUxLTE4LjA2OSAxMi42MTggMCAxMC40MDggMzEuNDQ4IDE4Ljg0NSA3MC4yMzkgMTguODQ1czcwLjIzOS04LjQzNzUgNzAuMjM5LTE4Ljg0NWMwLTQuODUyNS02LjgzNjItOS4yNzc0LTE4LjA2OS0xMi42MTggMC4wNDI1LTAuMDMxMiAwLjA4MjUtMC4wNjAxIDAuMTI1LTAuMDkxNCAxMS4zNDkgMy4zNTg5IDE4LjI2NCA3LjgxNzkgMTguMjY0IDEyLjcwOSAwIDEwLjQ1NS0zMS41OSAxOC45MzEtNzAuNTU5IDE4LjkzMSIgZmlsbD0iI2Y2ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDU2OTMiIGQ9Im0zMTkuODkgOTE4LjkzYy0zOC43OTEgMC03MC4yMzktOC40Mzc1LTcwLjIzOS0xOC44NDUgMC00Ljg1MjUgNi44Mzc1LTkuMjc3NCAxOC4wNjktMTIuNjE4IDAuMDQgMC4wMjg3IDAuMDg1IDAuMDYxNSAwLjEyNSAwLjA5MTItMTEuMTEyIDMuMzIxMy0xNy44NzIgNy43MTE0LTE3Ljg3MiAxMi41MjYgMCAxMC4zNiAzMS4zMDIgMTguNzU5IDY5LjkxOCAxOC43NTkgMzguNjE0IDAgNjkuOTE4LTguMzk4OSA2OS45MTgtMTguNzU5IDAtNC44MTUtNi43Ni05LjIwNTEtMTcuODcyLTEyLjUyNiAwLjA0LTAuMDI5NyAwLjA4NS0wLjA2MjUgMC4xMjUtMC4wOTEyIDExLjIzMiAzLjM0MDIgMTguMDY5IDcuNzY1MSAxOC4wNjkgMTIuNjE4IDAgMTAuNDA4LTMxLjQ0OCAxOC44NDUtNzAuMjM5IDE4Ljg0NSIgZmlsbD0iI2Y1ZjZmNiIvPgogICA8cGF0aCBpZD0icGF0aDU2OTUiIGQ9Im0zMTkuODkgOTE4Ljg0Yy0zOC42MTUgMC02OS45MTgtOC4zOTg5LTY5LjkxOC0xOC43NTkgMC00LjgxNSA2Ljc2LTkuMjA1MSAxNy44NzItMTIuNTI2IDAuMDQyNSAwLjAzMTIgMC4wODI1IDAuMDYwMSAwLjEyNjI1IDAuMDkxNC0xMC45OTUgMy4zMDI2LTE3LjY3OSA3LjY1ODYtMTcuNjc5IDEyLjQzNSAwIDEwLjMxMiAzMS4xNiAxOC42NzIgNjkuNTk4IDE4LjY3MnM2OS41OTYtOC4zNTk5IDY5LjU5Ni0xOC42NzJjMC00Ljc3NjQtNi42ODI1LTkuMTMyNC0xNy42NzYtMTIuNDM1IDAuMDQyNS0wLjAzMTIgMC4wODI1LTAuMDYwMSAwLjEyNS0wLjA5MTQgMTEuMTEyIDMuMzIxMyAxNy44NzIgNy43MTE0IDE3Ljg3MiAxMi41MjYgMCAxMC4zNi0zMS4zMDQgMTguNzU5LTY5LjkxOCAxOC43NTkiIGZpbGw9IiNmNWY1ZjUiLz4KICAgPHBhdGggaWQ9InBhdGg1Njk3IiBkPSJtMzE5Ljg5IDkxOC43NWMtMzguNDM4IDAtNjkuNTk4LTguMzU5OS02OS41OTgtMTguNjcyIDAtNC43NzY0IDYuNjgzOC05LjEzMjQgMTcuNjc5LTEyLjQzNSAwLjA0MjUgMC4wMzEyIDAuMDgyNSAwLjA2IDAuMTI1IDAuMDkxMy0xMC44NzYgMy4yODI4LTE3LjQ4MiA3LjYwNjUtMTcuNDgyIDEyLjM0NCAwIDEwLjI2NSAzMS4wMTYgMTguNTg2IDY5LjI3NiAxOC41ODZzNjkuMjc2LTguMzIxMyA2OS4yNzYtMTguNTg2YzAtNC43MzcyLTYuNjA2Mi05LjA2MS0xNy40ODItMTIuMzQ0IDAuMDQyNS0wLjAzMTIgMC4wODI1LTAuMDYgMC4xMjYyNS0wLjA5MTMgMTAuOTk0IDMuMzAyNiAxNy42NzYgNy42NTg2IDE3LjY3NiAxMi40MzUgMCAxMC4zMTItMzEuMTU5IDE4LjY3Mi02OS41OTYgMTguNjcyIiBmaWxsPSIjZjVmNWY1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTY5OSIgZD0ibTMxOS44OSA5MTguNjdjLTM4LjI2IDAtNjkuMjc2LTguMzIxMy02OS4yNzYtMTguNTg2IDAtNC43MzcyIDYuNjA2Mi05LjA2MSAxNy40ODItMTIuMzQ0IDAuMDQxMiAwLjAyOTcgMC4wODYzIDAuMDYyNSAwLjEyNzUgMC4wOTEyLTEwLjc1OSAzLjI2MzgtMTcuMjg5IDcuNTUzOC0xNy4yODkgMTIuMjUyIDAgMTAuMjE3IDMwLjg3MiAxOC41IDY4Ljk1NSAxOC41IDM4LjA4MiAwIDY4Ljk1NS04LjI4MjggNjguOTU1LTE4LjUgMC00LjY5ODgtNi41My04Ljk4ODgtMTcuMjg5LTEyLjI1MiAwLjA0MzctMC4wMzAzIDAuMDg1LTAuMDYgMC4xMjc1LTAuMDkxMiAxMC44NzYgMy4yODI4IDE3LjQ4MiA3LjYwNjUgMTcuNDgyIDEyLjM0NCAwIDEwLjI2NS0zMS4wMTYgMTguNTg2LTY5LjI3NiAxOC41ODYiIGZpbGw9IiNmNGY0ZjQiLz4KICAgPHBhdGggaWQ9InBhdGg1NzAxIiBkPSJtMzE5Ljg5IDkxOC41OGMtMzguMDgyIDAtNjguOTU1LTguMjgyOC02OC45NTUtMTguNSAwLTQuNjk4OCA2LjUzLTguOTg4OCAxNy4yODktMTIuMjUyIDAuMDQyNSAwLjAzMTIgMC4wODM4IDAuMDYxMSAwLjEyNjI1IDAuMDkxNC0xMC42NDEgMy4yNDM2LTE3LjA5NSA3LjUtMTcuMDk1IDEyLjE2MSAwIDEwLjE3IDMwLjcyOSAxOC40MTUgNjguNjM1IDE4LjQxNXM2OC42MzQtOC4yNDUyIDY4LjYzNC0xOC40MTVjMC00LjY2MTEtNi40NTI1LTguOTE2NS0xNy4wOTQtMTIuMTYgMC4wNC0wLjAyOTkgMC4wODYzLTAuMDYyNSAwLjEyNjI1LTAuMDkyNCAxMC43NTkgMy4yNjM4IDE3LjI4OSA3LjU1MzggMTcuMjg5IDEyLjI1MiAwIDEwLjIxNy0zMC44NzIgMTguNS02OC45NTUgMTguNSIgZmlsbD0iI2Y0ZjRmNCIvPgogICA8cGF0aCBpZD0icGF0aDU3MDMiIGQ9Im0zMTkuODkgOTE4LjVjLTM3LjkwNiAwLTY4LjYzNS04LjI0NTItNjguNjM1LTE4LjQxNSAwLTQuNjYxMSA2LjQ1MzgtOC45MTc1IDE3LjA5NS0xMi4xNjEgMC4wNDM3IDAuMDMxMiAwLjA4NSAwLjA2MSAwLjEyNzUgMC4wOTIyLTEwLjUyNCAzLjIyMzYtMTYuOTAxIDcuNDQ2NC0xNi45MDEgMTIuMDY5IDAgMTAuMTIyIDMwLjU4NSAxOC4zMjkgNjguMzE0IDE4LjMyOXM2OC4zMTQtOC4yMDYxIDY4LjMxNC0xOC4zMjljMC00LjYyMjUtNi4zNzc1LTguODQ1Mi0xNi45MDEtMTIuMDY5IDAuMDQyNS0wLjAzMTIgMC4wODUtMC4wNjEgMC4xMjc1LTAuMDkxMiAxMC42NDEgMy4yNDM2IDE3LjA5NCA3LjQ5OSAxNy4wOTQgMTIuMTYgMCAxMC4xNy0zMC43MjggMTguNDE1LTY4LjYzNCAxOC40MTUiIGZpbGw9IiNmM2YzZjMiLz4KICAgPHBhdGggaWQ9InBhdGg1NzA1IiBkPSJtMzE5Ljg5IDkxOC40MWMtMzcuNzI5IDAtNjguMzE0LTguMjA2MS02OC4zMTQtMTguMzI5IDAtNC42MjI1IDYuMzc3NS04Ljg0NTIgMTYuOTAxLTEyLjA2OSAwLjA0MzcgMC4wMzAzIDAuMDg1IDAuMDYwMSAwLjEyODc1IDAuMDkxNC0xMC40MDggMy4yMDM2LTE2LjcxIDcuMzkzNS0xNi43MSAxMS45NzggMCAxMC4wNzUgMzAuNDQyIDE4LjI0MyA2Ny45OTQgMTguMjQzIDM3LjU1MSAwIDY3Ljk5Mi04LjE2NzQgNjcuOTkyLTE4LjI0MyAwLTQuNTg0LTYuMzAxMi04Ljc3MjUtMTYuNzA5LTExLjk3OCAwLjA0MzctMC4wMzEyIDAuMDg2My0wLjA2MTEgMC4xMjg3NS0wLjA5MTQgMTAuNTI0IDMuMjIzNiAxNi45MDEgNy40NDY0IDE2LjkwMSAxMi4wNjkgMCAxMC4xMjItMzAuNTg1IDE4LjMyOS02OC4zMTQgMTguMzI5IiBmaWxsPSIjZjNmM2YyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTcwNyIgZD0ibTMxOS44OSA5MTguMzJjLTM3LjU1MSAwLTY3Ljk5NC04LjE2NzQtNjcuOTk0LTE4LjI0MyAwLTQuNTg0IDYuMzAyNS04Ljc3MzkgMTYuNzEtMTEuOTc4IDAuMDQyNSAwLjAyOTggMC4wODUgMC4wNjE1IDAuMTI4NzUgMC4wOTEzLTEwLjI5MSAzLjE4NTEtMTYuNTE4IDcuMzM5OS0xNi41MTggMTEuODg2IDAgMTAuMDI3IDMwLjI5OSAxOC4xNTYgNjcuNjcyIDE4LjE1NiAzNy4zNzQgMCA2Ny42NzItOC4xMjg5IDY3LjY3Mi0xOC4xNTYgMC00LjU0NDktNi4yMjg4LTguNzAxMS0xNi41MTYtMTEuODg2IDAuMDQyNS0wLjAyOTggMC4wODUtMC4wNjE1IDAuMTI3NS0wLjA5MTMgMTAuNDA4IDMuMjA1IDE2LjcwOSA3LjM5MzUgMTYuNzA5IDExLjk3OCAwIDEwLjA3NS0zMC40NDEgMTguMjQzLTY3Ljk5MiAxOC4yNDMiIGZpbGw9IiNmMmYyZjIiLz4KICAgPHBhdGggaWQ9InBhdGg1NzA5IiBkPSJtMzE5Ljg5IDkxOC4yNGMtMzcuMzc0IDAtNjcuNjcyLTguMTI4OS02Ny42NzItMTguMTU2IDAtNC41NDY0IDYuMjI2Mi04LjcwMTEgMTYuNTE4LTExLjg4NiAwLjA0MjUgMC4wMjk4IDAuMDg1IDAuMDYxIDAuMTI4NzUgMC4wOTE0LTEwLjE3NCAzLjE2NS0xNi4zMjUgNy4yODc1LTE2LjMyNSAxMS43OTUgMCA5Ljk4IDMwLjE1NCAxOC4wNyA2Ny4zNTEgMTguMDcgMzcuMTk4IDAgNjcuMzUxLTguMDg5OCA2Ny4zNTEtMTguMDcgMC00LjUwNzQtNi4xNTEyLTguNjI5OS0xNi4zMjUtMTEuNzk1IDAuMDQzNy0wLjAzMDQgMC4wODYyLTAuMDYxNiAwLjEzLTAuMDkxNCAxMC4yODggMy4xODUxIDE2LjUxNiA3LjM0MTQgMTYuNTE2IDExLjg4NiAwIDEwLjAyNy0zMC4yOTkgMTguMTU2LTY3LjY3MiAxOC4xNTYiIGZpbGw9IiNmMmYxZjEiLz4KICAgPHBhdGggaWQ9InBhdGg1NzExIiBkPSJtMzE5Ljg5IDkxOC4xNWMtMzcuMTk4IDAtNjcuMzUxLTguMDg5OC02Ny4zNTEtMTguMDcgMC00LjUwNzQgNi4xNTEyLTguNjI5OSAxNi4zMjUtMTEuNzk1IDAuMDQyNSAwLjAzMTIgMC4wODYzIDAuMDYxIDAuMTMgMC4wOTEzLTEwLjA1OCAzLjE0Ni0xNi4xMzUgNy4yMzQ5LTE2LjEzNSAxMS43MDQgMCA5LjkzMjYgMzAuMDExIDE3Ljk4NCA2Ny4wMzEgMTcuOTg0czY3LjAzLTguMDUxMyA2Ny4wMy0xNy45ODRjMC00LjQ2ODgtNi4wNzYyLTguNTU3Ni0xNi4xMzItMTEuNzAzIDAuMDQyNS0wLjAzMTIgMC4wODYzLTAuMDYxIDAuMTI4NzUtMC4wOTIzIDEwLjE3NCAzLjE2NSAxNi4zMjUgNy4yODc1IDE2LjMyNSAxMS43OTUgMCA5Ljk4LTMwLjE1NCAxOC4wNy02Ny4zNTEgMTguMDciIGZpbGw9IiNmMWYxZjEiLz4KICAgPHBhdGggaWQ9InBhdGg1NzEzIiBkPSJtMzE5Ljg5IDkxOC4wNmMtMzcuMDIgMC02Ny4wMzEtOC4wNTEzLTY3LjAzMS0xNy45ODQgMC00LjQ2ODggNi4wNzc1LTguNTU3NiAxNi4xMzUtMTEuNzA0IDAuMDQyNSAwLjAzMTIgMC4wODYzIDAuMDYxIDAuMTMgMC4wOTIyLTkuOTQzOCAzLjEyNS0xNS45NDQgNy4xODAyLTE1Ljk0NCAxMS42MTEgMCA5Ljg4NDggMjkuODY4IDE3Ljg5NyA2Ni43MSAxNy44OTdzNjYuNzEtOC4wMTI3IDY2LjcxLTE3Ljg5N2MwLTQuNDMxMS02LjAwMjUtOC40ODY0LTE1Ljk0Mi0xMS42MTEgMC4wNDI1LTAuMDI5NyAwLjA4NjMtMC4wNjEgMC4xMy0wLjA5MTMgMTAuMDU2IDMuMTQ1IDE2LjEzMiA3LjIzMzkgMTYuMTMyIDExLjcwMyAwIDkuOTMyNi0zMC4wMSAxNy45ODQtNjcuMDMgMTcuOTg0IiBmaWxsPSIjZjBmMGYwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTcxNSIgZD0ibTMxOS44OSA5MTcuOThjLTM2Ljg0MiAwLTY2LjcxLTguMDEyNy02Ni43MS0xNy44OTcgMC00LjQzMTEgNi04LjQ4NjQgMTUuOTQ0LTExLjYxMSAwLjA0MjUgMC4wMzA0IDAuMDg2MyAwLjA2MTYgMC4xMyAwLjA5MTQtOS44MjUgMy4xMDY0LTE1Ljc1MiA3LjEyNzQtMTUuNzUyIDExLjUyIDAgOS44Mzc0IDI5LjcyMiAxNy44MTIgNjYuMzg5IDE3LjgxMiAzNi42NjUgMCA2Ni4zODktNy45NzUxIDY2LjM4OS0xNy44MTIgMC00LjM5MjYtNS45Mjc1LTguNDEzNi0xNS43NTItMTEuNTIgMC4wNDM3LTAuMDI5OCAwLjA4NzUtMC4wNjEgMC4xMzEyNS0wLjA5MTQgOS45NCAzLjEyNSAxNS45NDIgNy4xODAyIDE1Ljk0MiAxMS42MTEgMCA5Ljg4NDgtMjkuODY4IDE3Ljg5Ny02Ni43MSAxNy44OTciIGZpbGw9IiNmMGYwZWYiLz4KICAgPHBhdGggaWQ9InBhdGg1NzE3IiBkPSJtMzE5Ljg5IDkxNy44OWMtMzYuNjY2IDAtNjYuMzg5LTcuOTc1MS02Ni4zODktMTcuODEyIDAtNC4zOTI2IDUuOTI3NS04LjQxMzYgMTUuNzUyLTExLjUyIDAuMDQ1IDAuMDMxMiAwLjA4NjIgMC4wNiAwLjEzMTI1IDAuMDkxMi05LjcxIDMuMDg2NS0xNS41NjQgNy4wNzM4LTE1LjU2NCAxMS40MjkgMCA5Ljc5IDI5LjU4IDE3LjcyNiA2Ni4wNjkgMTcuNzI2czY2LjA2OS03LjkzNjEgNjYuMDY5LTE3LjcyNmMwLTQuMzU1LTUuODUzOC04LjM0MjItMTUuNTY0LTExLjQyOSAwLjA0NjItMC4wMzEyIDAuMDg2My0wLjA1ODUgMC4xMzEyNS0wLjA5MTIgOS44MjUgMy4xMDY0IDE1Ljc1MiA3LjEyNzQgMTUuNzUyIDExLjUyIDAgOS44Mzc0LTI5LjcyNCAxNy44MTItNjYuMzg5IDE3LjgxMiIgZmlsbD0iI2VmZWZlZiIvPgogICA8cGF0aCBpZD0icGF0aDU3MTkiIGQ9Im0zMTkuODkgOTE3LjgxYy0zNi40ODkgMC02Ni4wNjktNy45MzYxLTY2LjA2OS0xNy43MjYgMC00LjM1NSA1Ljg1MzgtOC4zNDIyIDE1LjU2NC0xMS40MjkgMC4wNDM3IDAuMDI5OSAwLjA4ODggMC4wNjI1IDAuMTMxMjUgMC4wOTI0LTkuNTkzOCAzLjA2NDktMTUuMzc0IDcuMDItMTUuMzc0IDExLjMzNiAwIDkuNzQyNiAyOS40MzYgMTcuNjQgNjUuNzQ4IDE3LjY0IDM2LjMxMSAwIDY1Ljc0OC03Ljg5NzUgNjUuNzQ4LTE3LjY0IDAtNC4zMTY0LTUuNzgtOC4yNzE1LTE1LjM3NC0xMS4zMzYgMC4wNDI1LTAuMDI5OSAwLjA4NzUtMC4wNjExIDAuMTMxMjUtMC4wOTI0IDkuNzEgMy4wODY1IDE1LjU2NCA3LjA3MzggMTUuNTY0IDExLjQyOSAwIDkuNzktMjkuNTggMTcuNzI2LTY2LjA2OSAxNy43MjYiIGZpbGw9IiNlZWUiLz4KICAgPHBhdGggaWQ9InBhdGg1NzIxIiBkPSJtMzE5Ljg5IDkxNy43MmMtMzYuMzExIDAtNjUuNzQ4LTcuODk3NS02NS43NDgtMTcuNjQgMC00LjMxNjQgNS43OC04LjI3MTUgMTUuMzc0LTExLjMzNiAwLjA0MzcgMC4wMzAyIDAuMDg4OCAwLjA2MTUgMC4xMzI1IDAuMDkxMi05LjQ4MTIgMy4wNDY0LTE1LjE4NiA2Ljk2NjQtMTUuMTg2IDExLjI0NSAwIDkuNjk0OSAyOS4yOTIgMTcuNTU0IDY1LjQyOCAxNy41NTQgMzYuMTM0IDAgNjUuNDI2LTcuODU4OCA2NS40MjYtMTcuNTU0IDAtNC4yNzg4LTUuNzAzOC04LjE5ODgtMTUuMTg1LTExLjI0NSAwLjA0MzgtMC4wMjk3IDAuMDg4OC0wLjA2MSAwLjEzMjUtMC4wOTEyIDkuNTkzOCAzLjA2NDkgMTUuMzc0IDcuMDIgMTUuMzc0IDExLjMzNiAwIDkuNzQyNi0yOS40MzYgMTcuNjQtNjUuNzQ4IDE3LjY0IiBmaWxsPSIjZWVlZWVkIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTcyMyIgZD0ibTMxOS44OSA5MTcuNjNjLTM2LjEzNSAwLTY1LjQyOC03Ljg1ODgtNjUuNDI4LTE3LjU1NCAwLTQuMjc4OCA1LjcwNS04LjE5ODggMTUuMTg2LTExLjI0NSAwLjA0NjIgMC4wMzEyIDAuMDg3NSAwLjA2MDEgMC4xMzI1IDAuMDkxNC05LjM2NjIgMy4wMjYyLTE0Ljk5OCA2LjkxMzUtMTQuOTk4IDExLjE1NCAwIDkuNjQ3NSAyOS4xNDkgMTcuNDY3IDY1LjEwNiAxNy40NjcgMzUuOTU4IDAgNjUuMTA2LTcuODE5OCA2NS4xMDYtMTcuNDY3IDAtNC4yNDAyLTUuNjMxMi04LjEyNzUtMTQuOTk4LTExLjE1NCAwLjA0NS0wLjAzMTIgMC4wODYzLTAuMDYwMSAwLjEzMjUtMC4wOTE0IDkuNDgxMiAzLjA0NjQgMTUuMTg1IDYuOTY2NCAxNS4xODUgMTEuMjQ1IDAgOS42OTQ5LTI5LjI5MiAxNy41NTQtNjUuNDI2IDE3LjU1NCIgZmlsbD0iI2VkZWRlYyIvPgogICA8cGF0aCBpZD0icGF0aDU3MjUiIGQ9Im0zMTkuODkgOTE3LjU1Yy0zNS45NTggMC02NS4xMDYtNy44MTk4LTY1LjEwNi0xNy40NjcgMC00LjI0MDIgNS42MzEyLTguMTI3NSAxNC45OTgtMTEuMTU0IDAuMDQzNyAwLjAzMDMgMC4wOSAwLjA2MjUgMC4xMzM3NSAwLjA5MjMtOS4yNTEyIDMuMDA1NC0xNC44MSA2Ljg1ODktMTQuODEgMTEuMDYyIDAgOS42MDAxIDI5LjAwNSAxNy4zODEgNjQuNzg1IDE3LjM4MXM2NC43ODUtNy43ODEyIDY0Ljc4NS0xNy4zODFjMC00LjIwMjYtNS41NTg4LTguMDU2MS0xNC44MS0xMS4wNjIgMC4wNDM3LTAuMDI5OCAwLjA5LTAuMDYyIDAuMTMzNzUtMC4wOTIzIDkuMzY2MiAzLjAyNjIgMTQuOTk4IDYuOTEzNSAxNC45OTggMTEuMTU0IDAgOS42NDc1LTI5LjE0OSAxNy40NjctNjUuMTA2IDE3LjQ2NyIgZmlsbD0iI2VkZWRlYyIvPgogICA8cGF0aCBpZD0icGF0aDU3MjciIGQ9Im0zMTkuODkgOTE3LjQ2Yy0zNS43OCAwLTY0Ljc4NS03Ljc4MTItNjQuNzg1LTE3LjM4MSAwLTQuMjAyNiA1LjU1ODgtOC4wNTYxIDE0LjgxLTExLjA2MiAwLjA0NjIgMC4wMzE3IDAuMDg4OCAwLjA2IDAuMTMzNzUgMC4wOTEyLTkuMTM4OCAyLjk4NTQtMTQuNjI0IDYuODA2Ni0xNC42MjQgMTAuOTcgMCA5LjU1MjggMjguODYyIDE3LjI5NSA2NC40NjUgMTcuMjk1IDM1LjYwMiAwIDY0LjQ2NS03Ljc0MjIgNjQuNDY1LTE3LjI5NSAwLTQuMTYzNi01LjQ4NS03Ljk4NDktMTQuNjI0LTEwLjk3IDAuMDQ1LTAuMDMxMiAwLjA4NzUtMC4wNTk1IDAuMTMzNzUtMC4wOTEyIDkuMjUxMiAzLjAwNTQgMTQuODEgNi44NTg5IDE0LjgxIDExLjA2MiAwIDkuNjAwMS0yOS4wMDUgMTcuMzgxLTY0Ljc4NSAxNy4zODEiIGZpbGw9IiNlY2VjZWIiLz4KICAgPHBhdGggaWQ9InBhdGg1NzI5IiBkPSJtMzE5Ljg5IDkxNy4zN2MtMzUuNjAyIDAtNjQuNDY1LTcuNzQyMi02NC40NjUtMTcuMjk1IDAtNC4xNjM2IDUuNDg1LTcuOTg0OSAxNC42MjQtMTAuOTcgMC4wNDM3IDAuMDMwMyAwLjA5MTMgMC4wNjI1IDAuMTM1IDAuMDkxNC05LjAyNSAyLjk2NTItMTQuNDM4IDYuNzUyNC0xNC40MzggMTAuODc5IDAgOS41MDQ5IDI4LjcxOCAxNy4yMSA2NC4xNDQgMTcuMjFzNjQuMTQ0LTcuNzA1MSA2NC4xNDQtMTcuMjFjMC00LjEyNjUtNS40MTI1LTcuOTEzNi0xNC40MzgtMTAuODc4IDAuMDQzOC0wLjAzMDMgMC4wOTEzLTAuMDYyNSAwLjEzNS0wLjA5MjggOS4xMzg4IDIuOTg1NCAxNC42MjQgNi44MDY2IDE0LjYyNCAxMC45NyAwIDkuNTUyOC0yOC44NjIgMTcuMjk1LTY0LjQ2NSAxNy4yOTUiIGZpbGw9IiNlY2ViZWEiLz4KICAgPHBhdGggaWQ9InBhdGg1NzMxIiBkPSJtMzE5Ljg5IDkxNy4yOWMtMzUuNDI2IDAtNjQuMTQ0LTcuNzA1MS02NC4xNDQtMTcuMjEgMC00LjEyNjUgNS40MTI1LTcuOTEzNiAxNC40MzgtMTAuODc5IDAuMDQ2MyAwLjAzMTIgMC4wODg4IDAuMDYxNSAwLjEzNSAwLjA5MjgtOC45MTEyIDIuOTQzOS0xNC4yNTIgNi42OTcyLTE0LjI1MiAxMC43ODYgMCA5LjQ1NzUgMjguNTc1IDE3LjEyNCA2My44MjQgMTcuMTI0czYzLjgyMi03LjY2NiA2My44MjItMTcuMTI0YzAtNC4wODg5LTUuMzQtNy44NDIyLTE0LjI1MS0xMC43ODYgMC4wNDYyLTAuMDMxMiAwLjA5LTAuMDYwMSAwLjEzNS0wLjA5MTQgOS4wMjUgMi45NjM5IDE0LjQzOCA2Ljc1MSAxNC40MzggMTAuODc4IDAgOS41MDQ5LTI4LjcxOCAxNy4yMS02NC4xNDQgMTcuMjEiIGZpbGw9IiNlYmViZWEiLz4KICAgPHBhdGggaWQ9InBhdGg1NzMzIiBkPSJtMzE5Ljg5IDkxNy4yYy0zNS4yNDkgMC02My44MjQtNy42NjYtNjMuODI0LTE3LjEyNCAwLTQuMDg4OSA1LjM0MTItNy44NDIyIDE0LjI1Mi0xMC43ODYgMC4wNDM3IDAuMDI5NyAwLjA5MTMgMC4wNjI1IDAuMTM1IDAuMDkxMi04Ljc5ODggMi45MjM0LTE0LjA2NiA2LjY0NTItMTQuMDY2IDEwLjY5NSAwIDkuNDEwMSAyOC40MzEgMTcuMDM4IDYzLjUwMiAxNy4wMzhzNjMuNTAyLTcuNjI3NSA2My41MDItMTcuMDM4YzAtNC4wNDk4LTUuMjY3NS03Ljc3LTE0LjA2Ni0xMC42OTUgMC4wNDM3LTAuMDI4NyAwLjA5MTMtMC4wNjE1IDAuMTM1LTAuMDkxMiA4LjkxMTIgMi45NDM5IDE0LjI1MSA2LjY5NzIgMTQuMjUxIDEwLjc4NiAwIDkuNDU3NS0yOC41NzQgMTcuMTI0LTYzLjgyMiAxNy4xMjQiIGZpbGw9IiNlYWVhZTkiLz4KICAgPHBhdGggaWQ9InBhdGg1NzM1IiBkPSJtMzE5Ljg5IDkxNy4xMmMtMzUuMDcxIDAtNjMuNTAyLTcuNjI3NS02My41MDItMTcuMDM4IDAtNC4wNDk4IDUuMjY3NS03Ljc3MTUgMTQuMDY2LTEwLjY5NSAwLjA0NjIgMC4wMzEyIDAuMDkxMyAwLjA2MTEgMC4xMzYyNSAwLjA5MjQtOC42ODUgMi45MDIyLTEzLjg4MSA2LjU4OTgtMTMuODgxIDEwLjYwMiAwIDkuMzYyMiAyOC4yODYgMTYuOTUxIDYzLjE4MSAxNi45NTEgMzQuODk0IDAgNjMuMTgxLTcuNTg4OSA2My4xODEtMTYuOTUxIDAtNC4wMTI4LTUuMTk2Mi03LjY5ODgtMTMuODgxLTEwLjYwMiAwLjA0NjMtMC4wMzEyIDAuMDktMC4wNjExIDAuMTM2MjUtMC4wOTI0IDguNzk4OCAyLjkyNDkgMTQuMDY2IDYuNjQ1MSAxNC4wNjYgMTAuNjk1IDAgOS40MTAxLTI4LjQzMSAxNy4wMzgtNjMuNTAyIDE3LjAzOCIgZmlsbD0iI2VhZWFlOCIvPgogICA8cGF0aCBpZD0icGF0aDU3MzciIGQ9Im0zMTkuODkgOTE3LjAzYy0zNC44OTUgMC02My4xODEtNy41ODg5LTYzLjE4MS0xNi45NTEgMC00LjAxMjggNS4xOTYyLTcuNzAwMiAxMy44ODEtMTAuNjAyIDAuMDQ2MyAwLjAzMTIgMC4wOTEzIDAuMDYxIDAuMTM3NSAwLjA5MTMtOC41NzI1IDIuODgyNC0xMy42OTkgNi41Mzc2LTEzLjY5OSAxMC41MTEgMCA5LjMxNSAyOC4xNDQgMTYuODY1IDYyLjg2MSAxNi44NjUgMzQuNzE4IDAgNjIuODYxLTcuNTQ5OCA2Mi44NjEtMTYuODY1IDAtMy45NzM2LTUuMTI2Mi03LjYyNzUtMTMuNjk5LTEwLjUxMSAwLjA0NjItMC4wMzAzIDAuMDkxMy0wLjA2IDAuMTM3NS0wLjA5MTMgOC42ODUgMi45MDM4IDEzLjg4MSA2LjU4OTggMTMuODgxIDEwLjYwMiAwIDkuMzYyMi0yOC4yODggMTYuOTUxLTYzLjE4MSAxNi45NTEiIGZpbGw9IiNlOWU5ZTgiLz4KICAgPHBhdGggaWQ9InBhdGg1NzM5IiBkPSJtMzE5Ljg5IDkxNi45NGMtMzQuNzE4IDAtNjIuODYxLTcuNTQ5OC02Mi44NjEtMTYuODY1IDAtMy45NzM2IDUuMTI2Mi03LjYyODkgMTMuNjk5LTEwLjUxMSAwLjA0NjIgMC4wMzEyIDAuMDkxMyAwLjA2MSAwLjEzNzUgMC4wOTIzLTguNDYxMiAyLjg2MjktMTMuNTE1IDYuNDgzLTEzLjUxNSAxMC40MTkgMCA5LjI2NzYgMjggMTYuNzc5IDYyLjU0IDE2Ljc3OXM2Mi41NC03LjUxMTIgNjIuNTQtMTYuNzc5YzAtMy45MzYtNS4wNTM4LTcuNTU2MS0xMy41MTUtMTAuNDE5IDAuMDQ2Mi0wLjAzMTIgMC4wOTEyLTAuMDYxIDAuMTM3NS0wLjA5MjMgOC41NzI1IDIuODgzOCAxMy42OTkgNi41Mzc2IDEzLjY5OSAxMC41MTEgMCA5LjMxNS0yOC4xNDQgMTYuODY1LTYyLjg2MSAxNi44NjUiIGZpbGw9IiNlOWU4ZTciLz4KICAgPHBhdGggaWQ9InBhdGg1NzQxIiBkPSJtMzE5Ljg5IDkxNi44NmMtMzQuNTQgMC02Mi41NC03LjUxMTItNjIuNTQtMTYuNzc5IDAtMy45MzYgNS4wNTM4LTcuNTU2MSAxMy41MTUtMTAuNDE5IDAuMDQ2MiAwLjAzMTIgMC4wOTEzIDAuMDYxNiAwLjEzNzUgMC4wOTE0LTguMzUgMi44NDI4LTEzLjMzMSA2LjQyODgtMTMuMzMxIDEwLjMyOCAwIDkuMjIwMiAyNy44NTYgMTYuNjk0IDYyLjIxOSAxNi42OTQgMzQuMzYyIDAgNjIuMjE5LTcuNDczNiA2Mi4yMTktMTYuNjk0IDAtMy44OTg5LTQuOTgxMi03LjQ4NDktMTMuMzMxLTEwLjMyNiAwLjA0NjMtMC4wMzEyIDAuMDkxMy0wLjA2MTYgMC4xMzc1LTAuMDkyOSA4LjQ2MTIgMi44NjI5IDEzLjUxNSA2LjQ4MyAxMy41MTUgMTAuNDE5IDAgOS4yNjc2LTI4IDE2Ljc3OS02Mi41NCAxNi43NzkiIGZpbGw9IiNlOGU4ZTYiLz4KICAgPHBhdGggaWQ9InBhdGg1NzQzIiBkPSJtMzE5Ljg5IDkxNi43N2MtMzQuMzYyIDAtNjIuMjE5LTcuNDczNi02Mi4yMTktMTYuNjk0IDAtMy44OTg5IDQuOTgxMi03LjQ4NDkgMTMuMzMxLTEwLjMyOCAwLjA0NjIgMC4wMzEyIDAuMDkyNSAwLjA2MTUgMC4xMzg3NSAwLjA5MjgtOC4yMzc1IDIuODE5OS0xMy4xNSA2LjM3MzUtMTMuMTUgMTAuMjM1IDAgOS4xNzI0IDI3LjcxMiAxNi42MDcgNjEuODk5IDE2LjYwNyAzNC4xODUgMCA2MS44OTktNy40MzUxIDYxLjg5OS0xNi42MDcgMC0zLjg2MTQtNC45MTI1LTcuNDE1LTEzLjE1LTEwLjIzNSAwLjA0NjItMC4wMzAzIDAuMDkyNS0wLjA2MTUgMC4xMzg3NS0wLjA5MTMgOC4zNSAyLjg0MTMgMTMuMzMxIDYuNDI3MyAxMy4zMzEgMTAuMzI2IDAgOS4yMjAyLTI3Ljg1NiAxNi42OTQtNjIuMjE5IDE2LjY5NCIgZmlsbD0iI2U3ZTdlNiIvPgogICA8cGF0aCBpZD0icGF0aDU3NDUiIGQ9Im0zMTkuODkgOTE2LjY5Yy0zNC4xODYgMC02MS44OTktNy40MzUxLTYxLjg5OS0xNi42MDcgMC0zLjg2MTQgNC45MTI1LTcuNDE1IDEzLjE1LTEwLjIzNSAwLjA0NjIgMC4wMzEyIDAuMDkyNSAwLjA2MSAwLjEzODc1IDAuMDkyMi04LjEyNzUgMi43OTg5LTEyLjk2OCA2LjMxODktMTIuOTY4IDEwLjE0MyAwIDkuMTIzNSAyNy41NjkgMTYuNTIxIDYxLjU3OCAxNi41MjFzNjEuNTc4LTcuMzk4IDYxLjU3OC0xNi41MjFjMC0zLjgyMjgtNC44NDEyLTcuMzQzOC0xMi45NjgtMTAuMTQzIDAuMDQ2My0wLjAzMTIgMC4wOTI1LTAuMDYxIDAuMTM4NzUtMC4wOTIyIDguMjM3NSAyLjgxOTkgMTMuMTUgNi4zNzM1IDEzLjE1IDEwLjIzNSAwIDkuMTcyNC0yNy43MTQgMTYuNjA3LTYxLjg5OSAxNi42MDciIGZpbGw9IiNlN2U2ZTUiLz4KICAgPHBhdGggaWQ9InBhdGg1NzQ3IiBkPSJtMzE5Ljg5IDkxNi42Yy0zNC4wMDkgMC02MS41NzgtNy4zOTgtNjEuNTc4LTE2LjUyMSAwLTMuODIzOCA0Ljg0LTcuMzQzOCAxMi45NjgtMTAuMTQzIDAuMDQ2MyAwLjAzMDQgMC4wOTM3IDAuMDYxNiAwLjE0IDAuMDkxNC04LjAxNSAyLjc3ODgtMTIuNzg4IDYuMjY2MS0xMi43ODggMTAuMDUxIDAgOS4wNzc2IDI3LjQyNiAxNi40MzUgNjEuMjU4IDE2LjQzNSAzMy44MzEgMCA2MS4yNTgtNy4zNTc0IDYxLjI1OC0xNi40MzUgMC0zLjc4NTEtNC43NzEyLTcuMjcyNS0xMi43ODgtMTAuMDUxIDAuMDQ2Mi0wLjAyOTggMC4wOTM3LTAuMDYxIDAuMTQtMC4wOTE0IDguMTI2MiAyLjc5ODkgMTIuOTY4IDYuMzE5OSAxMi45NjggMTAuMTQzIDAgOS4xMjM1LTI3LjU2OSAxNi41MjEtNjEuNTc4IDE2LjUyMSIgZmlsbD0iI2U2ZTZlNCIvPgogICA8cGF0aCBpZD0icGF0aDU3NDkiIGQ9Im0zMTkuODkgOTE2LjUyYy0zMy44MzEgMC02MS4yNTgtNy4zNTc0LTYxLjI1OC0xNi40MzUgMC0zLjc4NTEgNC43NzI1LTcuMjcyNSAxMi43ODgtMTAuMDUxIDAuMDQ2MiAwLjAzMTIgMC4wOTM3IDAuMDYyNSAwLjE0IDAuMDkyMi03LjkwNjIgMi43NTc5LTEyLjYwNiA2LjIxMTUtMTIuNjA2IDkuOTU5IDAgOS4wMjg4IDI3LjI4MiAxNi4zNDkgNjAuOTM2IDE2LjM0OXM2MC45MzYtNy4zMTk5IDYwLjkzNi0xNi4zNDljMC0zLjc0NzUtNC43LTcuMjAxMS0xMi42MDYtOS45NTkgMC4wNDYzLTAuMDI5NyAwLjA5MzctMC4wNjEgMC4xNC0wLjA5MjIgOC4wMTYyIDIuNzc4OCAxMi43ODggNi4yNjYxIDEyLjc4OCAxMC4wNTEgMCA5LjA3NzYtMjcuNDI2IDE2LjQzNS02MS4yNTggMTYuNDM1IiBmaWxsPSIjZTVlNWUzIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTc1MSIgZD0ibTMxOS44OSA5MTYuNDNjLTMzLjY1NCAwLTYwLjkzNi03LjMxOTktNjAuOTM2LTE2LjM0OSAwLTMuNzQ3NSA0LjctNy4yMDExIDEyLjYwNi05Ljk1OSAwLjA0NzUgMC4wMzAzIDAuMDk1IDAuMDYxNSAwLjE0MTI1IDAuMDkyOC03Ljc5NSAyLjczNjQtMTIuNDI4IDYuMTU2Mi0xMi40MjggOS44NjYyIDAgOC45ODE1IDI3LjEzOSAxNi4yNjMgNjAuNjE2IDE2LjI2MyAzMy40NzggMCA2MC42MTUtNy4yODEyIDYwLjYxNS0xNi4yNjMgMC0zLjcxLTQuNjMxMi03LjEyOTktMTIuNDI2LTkuODY2MiAwLjA0NjItMC4wMzEyIDAuMDkzNy0wLjA2MjUgMC4xNDEyNS0wLjA5MjggNy45MDYyIDIuNzU3OSAxMi42MDYgNi4yMTE1IDEyLjYwNiA5Ljk1OSAwIDkuMDI4OC0yNy4yODIgMTYuMzQ5LTYwLjkzNiAxNi4zNDkiIGZpbGw9IiNlNGU0ZTMiLz4KICAgPHBhdGggaWQ9InBhdGg1NzUzIiBkPSJtMzE5Ljg5IDkxNi4zNGMtMzMuNDc4IDAtNjAuNjE2LTcuMjgxMi02MC42MTYtMTYuMjYzIDAtMy43MSA0LjYzMjUtNy4xMjk5IDEyLjQyOC05Ljg2NjIgMC4wNDc1IDAuMDI5OSAwLjA5NSAwLjA2MTEgMC4xNDI1IDAuMDkxNC03LjY4NjIgMi43MTQ5LTEyLjI0OSA2LjEwMjUtMTIuMjQ5IDkuNzc0OSAwIDguOTMzNiAyNi45OTUgMTYuMTc2IDYwLjI5NSAxNi4xNzZzNjAuMjk1LTcuMjQyNiA2MC4yOTUtMTYuMTc2YzAtMy42NzI0LTQuNTYyNS03LjA1ODYtMTIuMjQ5LTkuNzc0OSAwLjA0NzUtMC4wMzAyIDAuMDk1LTAuMDYxNSAwLjE0MjUtMC4wOTE0IDcuNzk1IDIuNzM2NCAxMi40MjYgNi4xNTYyIDEyLjQyNiA5Ljg2NjIgMCA4Ljk4MTUtMjcuMTM4IDE2LjI2My02MC42MTUgMTYuMjYzIiBmaWxsPSIjZTRlM2UyIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTc1NSIgZD0ibTMxOS44OSA5MTYuMjZjLTMzLjMgMC02MC4yOTUtNy4yNDI2LTYwLjI5NS0xNi4xNzYgMC0zLjY3MjQgNC41NjI1LTcuMDYgMTIuMjQ5LTkuNzc0OSAwLjA0ODcgMC4wMzIzIDAuMDkyNSAwLjA2MSAwLjE0MjUgMC4wOTIyLTcuNTc3NSAyLjY5MzktMTIuMDcgNi4wNDc5LTEyLjA3IDkuNjgyNiAwIDguODg2MiAyNi44NTEgMTYuMDkxIDU5Ljk3NCAxNi4wOTEgMzMuMTIyIDAgNTkuOTc0LTcuMjA1MSA1OS45NzQtMTYuMDkxIDAtMy42MzQ4LTQuNDkyNS02Ljk4ODgtMTIuMDY5LTkuNjgyNiAwLjA0ODctMC4wMzEyIDAuMDkyNS0wLjA2IDAuMTQxMjUtMC4wOTIyIDcuNjg2MiAyLjcxNjIgMTIuMjQ5IDYuMTAyNSAxMi4yNDkgOS43NzQ5IDAgOC45MzM2LTI2Ljk5NSAxNi4xNzYtNjAuMjk1IDE2LjE3NiIgZmlsbD0iI2UzZTNlMSIvPgogICA8cGF0aCBpZD0icGF0aDU3NTciIGQ9Im0zMTkuODkgOTE2LjE3Yy0zMy4xMjIgMC01OS45NzQtNy4yMDUxLTU5Ljk3NC0xNi4wOTEgMC0zLjYzNDggNC40OTI1LTYuOTg4OCAxMi4wNy05LjY4MjYgMC4wNDYzIDAuMDMwMyAwLjA5NjIgMC4wNjI1IDAuMTQyNSAwLjA5MjgtNy40Njc1IDIuNjcyNC0xMS44OTIgNS45OTIyLTExLjg5MiA5LjU4OTkgMCA4LjgzODkgMjYuNzA5IDE2LjAwNSA1OS42NTQgMTYuMDA1czU5LjY1Mi03LjE2NiA1OS42NTItMTYuMDA1YzAtMy41OTc2LTQuNDIzOC02LjkxNzUtMTEuODkxLTkuNTg5OSAwLjA0NjMtMC4wMzAzIDAuMDk2Mi0wLjA2MjUgMC4xNDM3NS0wLjA5MjggNy41NzYyIDIuNjkzOSAxMi4wNjkgNi4wNDc5IDEyLjA2OSA5LjY4MjYgMCA4Ljg4NjItMjYuODUxIDE2LjA5MS01OS45NzQgMTYuMDkxIiBmaWxsPSIjZTJlMmUwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTc1OSIgZD0ibTMxOS44OSA5MTYuMDhjLTMyLjk0NSAwLTU5LjY1NC03LjE2Ni01OS42NTQtMTYuMDA1IDAtMy41OTc2IDQuNDI1LTYuOTE3NSAxMS44OTItOS41ODk5IDAuMDQ4NyAwLjAzMTIgMC4wOTUgMC4wNjAxIDAuMTQzNzUgMC4wOTA5LTcuMzU4OCAyLjY1MjgtMTEuNzE1IDUuOTM5LTExLjcxNSA5LjQ5OSAwIDguNzkxIDI2LjU2NCAxNS45MTkgNTkuMzMyIDE1LjkxOSAzMi43NjkgMCA1OS4zMzItNy4xMjggNTkuMzMyLTE1LjkxOSAwLTMuNTYtNC4zNTYyLTYuODQ2Mi0xMS43MTUtOS40OTc1IDAuMDQ4OC0wLjAzMjIgMC4wOTUtMC4wNjExIDAuMTQzNzUtMC4wOTI0IDcuNDY3NSAyLjY3MjQgMTEuODkxIDUuOTkyMiAxMS44OTEgOS41ODk5IDAgOC44Mzg5LTI2LjcwOCAxNi4wMDUtNTkuNjUyIDE2LjAwNSIgZmlsbD0iI2UyZTFkZiIvPgogICA8cGF0aCBpZD0icGF0aDU3NjEiIGQ9Im0zMTkuODkgOTE2Yy0zMi43NjkgMC01OS4zMzItNy4xMjgtNTkuMzMyLTE1LjkxOSAwLTMuNTYgNC4zNTYyLTYuODQ2MiAxMS43MTUtOS40OTkgMC4wNDYzIDAuMDMwMyAwLjA5NzUgMC4wNjMgMC4xNDM3NSAwLjA5MjgtNy4yNSAyLjYyOTktMTEuNTM4IDUuODgzOC0xMS41MzggOS40MDYyIDAgOC43NDM2IDI2LjQyIDE1LjgzMyA1OS4wMTEgMTUuODMzczU5LjAxMS03LjA4ODkgNTkuMDExLTE1LjgzM2MwLTMuNTIyNS00LjI4NjItNi43NzY0LTExLjUzOC05LjQwNjIgMC4wNDYyLTAuMDI5OCAwLjA5NzUtMC4wNjEgMC4xNDM3NS0wLjA5MTMgNy4zNTg4IDIuNjUxMiAxMS43MTUgNS45Mzc1IDExLjcxNSA5LjQ5NzUgMCA4Ljc5MS0yNi41NjQgMTUuOTE5LTU5LjMzMiAxNS45MTkiIGZpbGw9IiNlMWUwZGYiLz4KICAgPHBhdGggaWQ9InBhdGg1NzYzIiBkPSJtMzE5Ljg5IDkxNS45MWMtMzIuNTkxIDAtNTkuMDExLTcuMDg4OS01OS4wMTEtMTUuODMzIDAtMy41MjI1IDQuMjg3NS02Ljc3NjQgMTEuNTM4LTkuNDA2MiAwLjA1IDAuMDMxMiAwLjA5NjIgMC4wNjEgMC4xNDUgMC4wOTIyLTcuMTQyNSAyLjYwODktMTEuMzYyIDUuODI5MS0xMS4zNjIgOS4zMTQgMCA4LjY5NjIgMjYuMjc4IDE1Ljc0NiA1OC42OTEgMTUuNzQ2IDMyLjQxNCAwIDU4LjY5MS03LjA0OTggNTguNjkxLTE1Ljc0NiAwLTMuNDg0OS00LjIyLTYuNzA1MS0xMS4zNjItOS4zMTQgMC4wNDg4LTAuMDMxMiAwLjA5NS0wLjA2MSAwLjE0NS0wLjA5MjIgNy4yNTEyIDIuNjI5OSAxMS41MzggNS44ODM4IDExLjUzOCA5LjQwNjIgMCA4Ljc0MzYtMjYuNDIgMTUuODMzLTU5LjAxMSAxNS44MzMiIGZpbGw9IiNlMGUwZGUiLz4KICAgPHBhdGggaWQ9InBhdGg1NzY1IiBkPSJtMzE5Ljg5IDkxNS44M2MtMzIuNDE0IDAtNTguNjkxLTcuMDQ5OC01OC42OTEtMTUuNzQ2IDAtMy40ODQ5IDQuMjItNi43MDUxIDExLjM2Mi05LjMxNCAwLjA0NzUgMC4wMzAzIDAuMDk4OCAwLjA2MjUgMC4xNDYyNSAwLjA5MjgtNy4wMzYyIDIuNTg2LTExLjE4OCA1Ljc3MzUtMTEuMTg4IDkuMjIxMiAwIDguNjQ4OSAyNi4xMzIgMTUuNjYgNTguMzcgMTUuNjYgMzIuMjM2IDAgNTguMzctNy4wMTEzIDU4LjM3LTE1LjY2IDAtMy40NDc4LTQuMTUyNS02LjYzNTItMTEuMTg4LTkuMjIxMiAwLjA0NzUtMC4wMzAzIDAuMDk4Ny0wLjA2MjUgMC4xNDYyNS0wLjA5MjggNy4xNDI1IDIuNjA4OSAxMS4zNjIgNS44MjkxIDExLjM2MiA5LjMxNCAwIDguNjk2Mi0yNi4yNzggMTUuNzQ2LTU4LjY5MSAxNS43NDYiIGZpbGw9IiNlMGRmZGQiLz4KICAgPHBhdGggaWQ9InBhdGg1NzY3IiBkPSJtMzE5Ljg5IDkxNS43NGMtMzIuMjM4IDAtNTguMzctNy4wMTEzLTU4LjM3LTE1LjY2IDAtMy40NDc4IDQuMTUxMi02LjYzNTIgMTEuMTg4LTkuMjIxMiAwLjA0ODggMC4wMzEyIDAuMDk2MiAwLjA2MTEgMC4xNDYyNSAwLjA5MjQtNi45Mjc1IDIuNTY0OS0xMS4wMTIgNS43MTg4LTExLjAxMiA5LjEyODkgMCA4LjYwMTEgMjUuOTg5IDE1LjU3NSA1OC4wNDkgMTUuNTc1czU4LjA0OS02Ljk3NDEgNTguMDQ5LTE1LjU3NWMwLTMuNDEwMS00LjA4NS02LjU2NC0xMS4wMTItOS4xMjg5IDAuMDUtMC4wMzEyIDAuMDk3NS0wLjA2MTEgMC4xNDYyNS0wLjA5MjQgNy4wMzUgMi41ODYgMTEuMTg4IDUuNzczNSAxMS4xODggOS4yMjEyIDAgOC42NDg5LTI2LjEzNCAxNS42Ni01OC4zNyAxNS42NiIgZmlsbD0iI2RmZGVkYyIvPgogICA8cGF0aCBpZD0icGF0aDU3NjkiIGQ9Im0zMTkuODkgOTE1LjY2Yy0zMi4wNiAwLTU4LjA0OS02Ljk3NDEtNTguMDQ5LTE1LjU3NSAwLTMuNDEwMSA0LjA4NS02LjU2NCAxMS4wMTItOS4xMjg5IDAuMDQ4NyAwLjAzMTIgMC4wOTc1IDAuMDYxNSAwLjE0NjI1IDAuMDkyOC02LjgyMTIgMi41NDI1LTEwLjgzOSA1LjY2MzYtMTAuODM5IDkuMDM2MSAwIDguNTUzOCAyNS44NDYgMTUuNDg5IDU3LjcyOSAxNS40ODkgMzEuODgyIDAgNTcuNzI5LTYuOTM1IDU3LjcyOS0xNS40ODkgMC0zLjM3MjUtNC4wMTc1LTYuNDkyNi0xMC44MzktOS4wMzYxIDAuMDQ4OC0wLjAzMTIgMC4wOTc1LTAuMDYxNSAwLjE0NjI1LTAuMDkyOCA2LjkyNzUgMi41NjQ5IDExLjAxMiA1LjcxODggMTEuMDEyIDkuMTI4OSAwIDguNjAxMS0yNS45ODkgMTUuNTc1LTU4LjA0OSAxNS41NzUiIGZpbGw9IiNkZWRkZGIiLz4KICAgPHBhdGggaWQ9InBhdGg1NzcxIiBkPSJtMzE5Ljg5IDkxNS41N2MtMzEuODgyIDAtNTcuNzI5LTYuOTM1LTU3LjcyOS0xNS40ODkgMC0zLjM3MjUgNC4wMTc1LTYuNDkzNiAxMC44MzktOS4wMzYxIDAuMDUgMC4wMzEyIDAuMDk4NyAwLjA2MSAwLjE0ODc1IDAuMDkyMi02LjcxNSAyLjUyMTUtMTAuNjY2IDUuNjA4OS0xMC42NjYgOC45NDM5IDAgOC41MDY0IDI1LjcwMiAxNS40MDIgNTcuNDA4IDE1LjQwMiAzMS43MDUgMCA1Ny40MDgtNi44OTYgNTcuNDA4LTE1LjQwMiAwLTMuMzM1LTMuOTUtNi40MjI0LTEwLjY2Ni04Ljk0MzkgMC4wNS0wLjAzMTIgMC4wOTg4LTAuMDYxIDAuMTQ4NzUtMC4wOTIyIDYuODIxMiAyLjU0MzUgMTAuODM5IDUuNjYzNiAxMC44MzkgOS4wMzYxIDAgOC41NTM4LTI1Ljg0NiAxNS40ODktNTcuNzI5IDE1LjQ4OSIgZmlsbD0iI2RkZGNkYSIvPgogICA8cGF0aCBpZD0icGF0aDU3NzMiIGQ9Im0zMTkuODkgOTE1LjQ4Yy0zMS43MDUgMC01Ny40MDgtNi44OTYtNTcuNDA4LTE1LjQwMiAwLTMuMzM1IDMuOTUxMi02LjQyMjQgMTAuNjY2LTguOTQzOSAwLjA0ODcgMC4wMzAzIDAuMDk4NyAwLjA2MTUgMC4xNDg3NSAwLjA5MjgtNi42MSAyLjQ5ODYtMTAuNDk1IDUuNTUzOC0xMC40OTUgOC44NTExIDAgOC40NTkgMjUuNTU5IDE1LjMxNiA1Ny4wODggMTUuMzE2IDMxLjUyOCAwIDU3LjA4OC02Ljg1NzQgNTcuMDg4LTE1LjMxNiAwLTMuMjk3NC0zLjg4NS02LjM1MjUtMTAuNDk0LTguODUxMSAwLjA0ODctMC4wMzEyIDAuMDk4Ny0wLjA2MjUgMC4xNDc1LTAuMDkyOCA2LjcxNjIgMi41MjE1IDEwLjY2NiA1LjYwODkgMTAuNjY2IDguOTQzOSAwIDguNTA2NC0yNS43MDIgMTUuNDAyLTU3LjQwOCAxNS40MDIiIGZpbGw9IiNkZGRjZGEiLz4KICAgPHBhdGggaWQ9InBhdGg1Nzc1IiBkPSJtMzE5Ljg5IDkxNS40Yy0zMS41MjkgMC01Ny4wODgtNi44NTc0LTU3LjA4OC0xNS4zMTYgMC0zLjI5NzQgMy44ODUtNi4zNTI1IDEwLjQ5NS04Ljg1MTEgMC4wNDg3IDAuMDI5OSAwLjA5ODcgMC4wNjExIDAuMTQ4NzUgMC4wOTI0LTYuNTAzOCAyLjQ3NzUtMTAuMzIyIDUuNDk5LTEwLjMyMiA4Ljc1ODggMCA4LjQxMTEgMjUuNDE1IDE1LjIzIDU2Ljc2NiAxNS4yM3M1Ni43NjYtNi44MTg4IDU2Ljc2Ni0xNS4yM2MwLTMuMjU5OC0zLjgxODgtNi4yODEyLTEwLjMyMi04Ljc1ODggMC4wNS0wLjAzMTIgMC4xLTAuMDYyNSAwLjE1LTAuMDkyNCA2LjYwODggMi40OTg2IDEwLjQ5NCA1LjU1MzggMTAuNDk0IDguODUxMSAwIDguNDU5LTI1LjU2IDE1LjMxNi01Ny4wODggMTUuMzE2IiBmaWxsPSIjZGNkYmQ5Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTc3NyIgZD0ibTMxOS44OSA5MTUuMzFjLTMxLjM1MSAwLTU2Ljc2Ni02LjgxODgtNTYuNzY2LTE1LjIzIDAtMy4yNTk4IDMuODE4OC02LjI4MTIgMTAuMzIyLTguNzU4OCAwLjA0ODcgMC4wMzAzIDAuMSAwLjA2MSAwLjE1IDAuMDkyMy02LjM5ODggMi40NTUxLTEwLjE1MSA1LjQ0MzktMTAuMTUxIDguNjY2NSAwIDguMzYzOCAyNS4yNzEgMTUuMTQ0IDU2LjQ0NSAxNS4xNDRzNTYuNDQ1LTYuNzc5OCA1Ni40NDUtMTUuMTQ0YzAtMy4yMjI2LTMuNzUyNS02LjIxMTQtMTAuMTUxLTguNjY2NSAwLjA1LTAuMDMxMiAwLjEwMTI1LTAuMDYyIDAuMTUtMC4wOTIzIDYuNTAzOCAyLjQ3NzUgMTAuMzIyIDUuNDk5IDEwLjMyMiA4Ljc1ODggMCA4LjQxMTEtMjUuNDE1IDE1LjIzLTU2Ljc2NiAxNS4yMyIgZmlsbD0iI2RiZGFkOCIvPgogICA8cGF0aCBpZD0icGF0aDU3NzkiIGQ9Im0zMTkuODkgOTE1LjIyYy0zMS4xNzQgMC01Ni40NDUtNi43Nzk4LTU2LjQ0NS0xNS4xNDQgMC0zLjIyMjYgMy43NTI1LTYuMjExNCAxMC4xNTEtOC42NjY1IDAuMDQ4NyAwLjAzMDMgMC4xIDAuMDYxNSAwLjE1IDAuMDkyOC02LjI5MzggMi40MzI2LTkuOTgxMiA1LjM4NzgtOS45ODEyIDguNTczOCAwIDguMzE2NCAyNS4xMjkgMTUuMDU4IDU2LjEyNSAxNS4wNThzNTYuMTI1LTYuNzQxMiA1Ni4xMjUtMTUuMDU4YzAtMy4xODYtMy42ODc1LTYuMTQxMS05Ljk4MTItOC41NzM4IDAuMDUtMC4wMzEyIDAuMTAxMjUtMC4wNjI1IDAuMTUtMC4wOTI4IDYuMzk4OCAyLjQ1NTEgMTAuMTUxIDUuNDQzOSAxMC4xNTEgOC42NjY1IDAgOC4zNjM4LTI1LjI3MSAxNS4xNDQtNTYuNDQ1IDE1LjE0NCIgZmlsbD0iI2RhZDlkNyIvPgogICA8cGF0aCBpZD0icGF0aDU3ODEiIGQ9Im0zMTkuODkgOTE1LjE0Yy0zMC45OTYgMC01Ni4xMjUtNi43NDEyLTU2LjEyNS0xNS4wNTggMC0zLjE4NiAzLjY4NzUtNi4xNDExIDkuOTgxMi04LjU3MzggMC4wNSAwLjAyOTggMC4xMDEyNSAwLjA2MSAwLjE1MTI1IDAuMDkyMy02LjE4NzUgMi40MTE2LTkuODExMiA1LjMzMjYtOS44MTEyIDguNDgxNSAwIDguMjY4NSAyNC45ODQgMTQuOTczIDU1LjgwNCAxNC45NzNzNTUuODA0LTYuNzA0MiA1NS44MDQtMTQuOTczYzAtMy4xNDg5LTMuNjIyNS02LjA2OTktOS44MTEyLTguNDgxNSAwLjA1LTAuMDI5OCAwLjEwMTI1LTAuMDYyNSAwLjE1MTI1LTAuMDkyMyA2LjI5MzggMi40MzI2IDkuOTgxMiA1LjM4NzggOS45ODEyIDguNTczOCAwIDguMzE2NC0yNS4xMjkgMTUuMDU4LTU2LjEyNSAxNS4wNTgiIGZpbGw9IiNkOWQ4ZDYiLz4KICAgPHBhdGggaWQ9InBhdGg1NzgzIiBkPSJtMzE5Ljg5IDkxNS4wNWMtMzAuODIgMC01NS44MDQtNi43MDQyLTU1LjgwNC0xNC45NzMgMC0zLjE0ODkgMy42MjM4LTYuMDY5OSA5LjgxMTItOC40ODE1IDAuMDUyNSAwLjAzMTIgMC4xIDAuMDYwMSAwLjE1MjUgMC4wOTI5LTYuMDg1IDIuMzg4Ni05LjY0MzggNS4yNzczLTkuNjQzOCA4LjM4ODYgMCA4LjIyMTIgMjQuODQxIDE0Ljg4NiA1NS40ODQgMTQuODg2IDMwLjY0MiAwIDU1LjQ4NC02LjY2NSA1NS40ODQtMTQuODg2IDAtMy4xMTE0LTMuNTU4OC02LTkuNjQzOC04LjM4ODYgMC4wNTI1LTAuMDMxMiAwLjEtMC4wNjE2IDAuMTUyNS0wLjA5MjkgNi4xODg4IDIuNDExNiA5LjgxMTIgNS4zMzI2IDkuODExMiA4LjQ4MTUgMCA4LjI2ODUtMjQuOTg0IDE0Ljk3My01NS44MDQgMTQuOTczIiBmaWxsPSIjZDhkN2Q2Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTc4NSIgZD0ibTMxOS44OSA5MTQuOTdjLTMwLjY0MiAwLTU1LjQ4NC02LjY2NS01NS40ODQtMTQuODg2IDAtMy4xMTE0IDMuNTU4OC02IDkuNjQzOC04LjM4ODYgMC4wNSAwLjAyOTggMC4xMDI1IDAuMDYyNSAwLjE1MjUgMC4wOTIzLTUuOTgxMiAyLjM2NjMtOS40NzUgNS4yMjExLTkuNDc1IDguMjk2NCAwIDguMTczOSAyNC42OTggMTQuOCA1NS4xNjIgMTQuOCAzMC40NjUgMCA1NS4xNjItNi42MjU5IDU1LjE2Mi0xNC44IDAtMy4wNzUyLTMuNDkzOC01LjkzMDEtOS40NzUtOC4yOTY0IDAuMDUtMC4wMjk4IDAuMTAyNS0wLjA2MjUgMC4xNTI1LTAuMDkyMyA2LjA4NSAyLjM4ODYgOS42NDM4IDUuMjc3MyA5LjY0MzggOC4zODg2IDAgOC4yMjEyLTI0Ljg0MSAxNC44ODYtNTUuNDg0IDE0Ljg4NiIgZmlsbD0iI2Q3ZDZkNSIvPgogICA8cGF0aCBpZD0icGF0aDU3ODciIGQ9Im0zMTkuODkgOTE0Ljg4Yy0zMC40NjUgMC01NS4xNjItNi42MjU5LTU1LjE2Mi0xNC44IDAtMy4wNzUyIDMuNDkzOC01LjkzMDEgOS40NzUtOC4yOTY0IDAuMDUyNSAwLjAzMTIgMC4xMDEyNSAwLjA2MTUgMC4xNTM3NSAwLjA5MjgtNS44Nzc1IDIuMzQzOC05LjMwNzUgNS4xNjYtOS4zMDc1IDguMjAzNiAwIDguMTI2NSAyNC41NTIgMTQuNzE0IDU0Ljg0MSAxNC43MTQgMzAuMjg4IDAgNTQuODQxLTYuNTg3NCA1NC44NDEtMTQuNzE0IDAtMy4wMzc2LTMuNDMtNS44NTk5LTkuMzA3NS04LjIwMzYgMC4wNTI1LTAuMDMxMiAwLjEwMTI1LTAuMDYgMC4xNTM3NS0wLjA5MjggNS45ODEyIDIuMzY2MyA5LjQ3NSA1LjIyMTEgOS40NzUgOC4yOTY0IDAgOC4xNzM5LTI0LjY5OCAxNC44LTU1LjE2MiAxNC44IiBmaWxsPSIjZDZkNmQ0Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTc4OSIgZD0ibTMxOS44OSA5MTQuNzljLTMwLjI4OSAwLTU0Ljg0MS02LjU4NzQtNTQuODQxLTE0LjcxNCAwLTMuMDM3NiAzLjQzLTUuODU5OSA5LjMwNzUtOC4yMDM2IDAuMDUgMC4wMjk3IDAuMTA1IDAuMDYyNSAwLjE1NSAwLjA5MjItNS43NzYyIDIuMzIyNy05LjE0MjUgNS4xMTE0LTkuMTQyNSA4LjExMTQgMCA4LjA3ODYgMjQuNDEgMTQuNjI3IDU0LjUyMSAxNC42MjdzNTQuNTIxLTYuNTQ4OCA1NC41MjEtMTQuNjI3YzAtMy0zLjM2NjItNS43ODg2LTkuMTQyNS04LjEwOTkgMC4wNTEyLTAuMDMwMyAwLjEwNS0wLjA2NCAwLjE1NS0wLjA5MzcgNS44Nzc1IDIuMzQzOCA5LjMwNzUgNS4xNjYgOS4zMDc1IDguMjAzNiAwIDguMTI2NS0yNC41NTQgMTQuNzE0LTU0Ljg0MSAxNC43MTQiIGZpbGw9IiNkNWQ1ZDMiLz4KICAgPHBhdGggaWQ9InBhdGg1NzkxIiBkPSJtMzE5Ljg5IDkxNC43MWMtMzAuMTExIDAtNTQuNTIxLTYuNTQ4OC01NC41MjEtMTQuNjI3IDAtMyAzLjM2NjItNS43ODg2IDkuMTQyNS04LjExMTQgMC4wNTI1IDAuMDMyOCAwLjEwMjUgMC4wNjI1IDAuMTU1IDAuMDkzNy01LjY3MjUgMi4yOTg5LTguOTc2MiA1LjA1NTItOC45NzYyIDguMDE3NiAwIDguMDMxMiAyNC4yNjYgMTQuNTQxIDU0LjIgMTQuNTQxczU0LjItNi41MDk4IDU0LjItMTQuNTQxYzAtMi45NjI0LTMuMzAzOC01LjcxODgtOC45NzYyLTguMDE3NiAwLjA1MjUtMC4wMzEyIDAuMTAyNS0wLjA2MSAwLjE1NS0wLjA5MjIgNS43NzYyIDIuMzIxMiA5LjE0MjUgNS4xMDk5IDkuMTQyNSA4LjEwOTkgMCA4LjA3ODYtMjQuNDEgMTQuNjI3LTU0LjUyMSAxNC42MjciIGZpbGw9IiNkNGQ0ZDIiLz4KICAgPHBhdGggaWQ9InBhdGg1NzkzIiBkPSJtMzE5Ljg5IDkxNC42MmMtMjkuOTM0IDAtNTQuMi02LjUwOTgtNTQuMi0xNC41NDEgMC0yLjk2MjQgMy4zMDM4LTUuNzE4OCA4Ljk3NjItOC4wMTc2IDAuMDUyNSAwLjAzMTIgMC4xMDM3NSAwLjA2MTYgMC4xNTYyNSAwLjA5MjktNS41NzEyIDIuMjc1OS04LjgxMjUgNC45OTg1LTguODEyNSA3LjkyNDggMCA3Ljk4MzkgMjQuMTIyIDE0LjQ1NSA1My44OCAxNC40NTUgMjkuNzU2IDAgNTMuODc5LTYuNDcxMiA1My44NzktMTQuNDU1IDAtMi45MjYyLTMuMjQtNS42NDg5LTguODExMi03LjkyNDggMC4wNTM4LTAuMDMxMiAwLjEwMzc1LTAuMDYxNiAwLjE1NjI1LTAuMDkyOSA1LjY3MjUgMi4yOTg5IDguOTc2MiA1LjA1NTIgOC45NzYyIDguMDE3NiAwIDguMDMxMi0yNC4yNjYgMTQuNTQxLTU0LjIgMTQuNTQxIiBmaWxsPSIjZDRkM2QxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTc5NSIgZD0ibTMxOS44OSA5MTQuNTRjLTI5Ljc1OCAwLTUzLjg4LTYuNDcxMi01My44OC0xNC40NTUgMC0yLjkyNjIgMy4yNDEyLTUuNjQ4OSA4LjgxMjUtNy45MjQ4IDAuMDUyNSAwLjAzMTIgMC4xMDM3NSAwLjA2MSAwLjE1NjI1IDAuMDkyMy01LjQ3IDIuMjUzOS04LjY0NzUgNC45NDM5LTguNjQ3NSA3LjgzMjUgMCA3LjkzNiAyMy45NzkgMTQuMzcgNTMuNTU5IDE0LjM3IDI5LjU3OSAwIDUzLjU1OS02LjQzNDEgNTMuNTU5LTE0LjM3IDAtMi44ODg2LTMuMTc3NS01LjU3ODYtOC42NDc1LTcuODMyNSAwLjA1MjUtMC4wMjk4IDAuMTAzNzUtMC4wNjEgMC4xNTYyNS0wLjA5MjMgNS41NzEyIDIuMjc1OSA4LjgxMTIgNC45OTg1IDguODExMiA3LjkyNDggMCA3Ljk4MzktMjQuMTIyIDE0LjQ1NS01My44NzkgMTQuNDU1IiBmaWxsPSIjZDNkMmQwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTc5NyIgZD0ibTMxOS44OSA5MTQuNDVjLTI5LjU4IDAtNTMuNTU5LTYuNDM0MS01My41NTktMTQuMzcgMC0yLjg4ODYgMy4xNzc1LTUuNTc4NiA4LjY0NzUtNy44MzI1IDAuMDUgMC4wMjk3IDAuMTA3NSAwLjA2NCAwLjE1NzUgMC4wOTM3LTUuMzY4OCAyLjIzLTguNDgzOCA0Ljg4NjItOC40ODM4IDcuNzM4OCAwIDcuODg4NiAyMy44MzUgMTQuMjg0IDUzLjIzOCAxNC4yODQgMjkuNDAyIDAgNTMuMjM4LTYuMzk1MSA1My4yMzgtMTQuMjg0IDAtMi44NTI1LTMuMTE1LTUuNTA4OC04LjQ4MzgtNy43Mzg4IDAuMDUtMC4wMjk4IDAuMTA3NS0wLjA2NCAwLjE1NzUtMC4wOTM3IDUuNDcgMi4yNTM5IDguNjQ3NSA0Ljk0MzkgOC42NDc1IDcuODMyNSAwIDcuOTM2LTIzLjk4IDE0LjM3LTUzLjU1OSAxNC4zNyIgZmlsbD0iI2QyZDFjZiIvPgogICA8cGF0aCBpZD0icGF0aDU3OTkiIGQ9Im0zMTkuODkgOTE0LjM2Yy0yOS40MDIgMC01My4yMzgtNi4zOTUxLTUzLjIzOC0xNC4yODQgMC0yLjg1MjUgMy4xMTUtNS41MDg4IDguNDgzOC03LjczODggMC4wNTI1IDAuMDMxMiAwLjEwNjI1IDAuMDYxIDAuMTU4NzUgMC4wOTIyLTUuMjY4OCAyLjIwNzUtOC4zMjI1IDQuODMxNS04LjMyMjUgNy42NDY1IDAgNy44NDEyIDIzLjY5MiAxNC4xOTcgNTIuOTE4IDE0LjE5NyAyOS4yMjUgMCA1Mi45MTgtNi4zNTYgNTIuOTE4LTE0LjE5NyAwLTIuODE1LTMuMDUzOC01LjQzOS04LjMyMjUtNy42NDY1IDAuMDUyNS0wLjAyOTcgMC4xMDYyNS0wLjA2MSAwLjE1ODc1LTAuMDkyMiA1LjM2ODggMi4yMyA4LjQ4MzggNC44ODYyIDguNDgzOCA3LjczODggMCA3Ljg4ODYtMjMuODM1IDE0LjI4NC01My4yMzggMTQuMjg0IiBmaWxsPSIjZDFkMGNmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgwMSIgZD0ibTMxOS44OSA5MTQuMjhjLTI5LjIyNSAwLTUyLjkxOC02LjM1Ni01Mi45MTgtMTQuMTk3IDAtMi44MTUgMy4wNTM4LTUuNDM5IDguMzIyNS03LjY0NjUgMC4wNTUgMC4wMzI4IDAuMTAzNzUgMC4wNjE1IDAuMTU4NzUgMC4wOTM3LTUuMTY3NSAyLjE4NDEtOC4xNiA0Ljc3NC04LjE2IDcuNTUyOCAwIDcuNzk0IDIzLjU0OCAxNC4xMTEgNTIuNTk2IDE0LjExMSAyOS4wNDkgMCA1Mi41OTYtNi4zMTczIDUyLjU5Ni0xNC4xMTEgMC0yLjc3ODgtMi45OTI1LTUuMzY4Ni04LjE2LTcuNTUyOCAwLjA1NS0wLjAzMjIgMC4xMDM3NS0wLjA2MSAwLjE1ODc1LTAuMDkzNyA1LjI2ODggMi4yMDc1IDguMzIyNSA0LjgzMTUgOC4zMjI1IDcuNjQ2NSAwIDcuODQxMi0yMy42OTIgMTQuMTk3LTUyLjkxOCAxNC4xOTciIGZpbGw9IiNkMGNmY2QiLz4KICAgPHBhdGggaWQ9InBhdGg1ODAzIiBkPSJtMzE5Ljg5IDkxNC4xOWMtMjkuMDQ5IDAtNTIuNTk2LTYuMzE3My01Mi41OTYtMTQuMTExIDAtMi43Nzg4IDIuOTkyNS01LjM2ODYgOC4xNi03LjU1MjggMC4wNTM3IDAuMDMwMyAwLjEwNzUgMC4wNjI1IDAuMTYxMjUgMC4wOTI4LTUuMDY4OCAyLjE2MTEtOCA0LjcxODgtOCA3LjQ2IDAgNy43NDYxIDIzLjQwNCAxNC4wMjUgNTIuMjc1IDE0LjAyNXM1Mi4yNzUtNi4yNzg4IDUyLjI3NS0xNC4wMjVjMC0yLjc0MTItMi45MzEyLTUuMjk4OS04LTcuNDYgMC4wNTM3LTAuMDMwMyAwLjEwNzUtMC4wNjI1IDAuMTYxMjUtMC4wOTI4IDUuMTY3NSAyLjE4NDEgOC4xNiA0Ljc3NCA4LjE2IDcuNTUyOCAwIDcuNzk0LTIzLjU0OCAxNC4xMTEtNTIuNTk2IDE0LjExMSIgZmlsbD0iI2NmY2VjYyIvPgogICA8cGF0aCBpZD0icGF0aDU4MDUiIGQ9Im0zMTkuODkgOTE0LjFjLTI4Ljg3MSAwLTUyLjI3NS02LjI3ODgtNTIuMjc1LTE0LjAyNSAwLTIuNzQxMiAyLjkzMTItNS4yOTg5IDgtNy40NiAwLjA1MjUgMC4wMzEyIDAuMTA3NSAwLjA2MjUgMC4xNiAwLjA5MzctNC45Njg4IDIuMTM3Mi03Ljg0IDQuNjYxMS03Ljg0IDcuMzY2MiAwIDcuNjk4OCAyMy4yNjEgMTMuOTQgNTEuOTU1IDEzLjk0czUxLjk1NS02LjI0MTIgNTEuOTU1LTEzLjk0YzAtMi43MDUxLTIuODcxMi01LjIyOS03Ljg0LTcuMzY2MiAwLjA1MjUtMC4wMzEyIDAuMTA3NS0wLjA2MjUgMC4xNi0wLjA5MzcgNS4wNjg4IDIuMTYxMSA4IDQuNzE4OCA4IDcuNDYgMCA3Ljc0NjEtMjMuNDA0IDE0LjAyNS01Mi4yNzUgMTQuMDI1IiBmaWxsPSIjY2VjZGNiIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgwNyIgZD0ibTMxOS44OSA5MTQuMDJjLTI4LjY5NCAwLTUxLjk1NS02LjI0MTItNTEuOTU1LTEzLjk0IDAtMi43MDUxIDIuODcxMi01LjIyOSA3Ljg0LTcuMzY2MiAwLjA1MzggMC4wMjk5IDAuMTA4NzUgMC4wNjI1IDAuMTYyNSAwLjA5MjQtNC44NyAyLjExMzgtNy42ODEyIDQuNjA2NC03LjY4MTIgNy4yNzM5IDAgNy42NTE0IDIzLjExOCAxMy44NTQgNTEuNjM0IDEzLjg1NHM1MS42MzQtNi4yMDIxIDUxLjYzNC0xMy44NTRjMC0yLjY2NzUtMi44MTEyLTUuMTU4OC03LjY4MTItNy4yNzM5IDAuMDUzOC0wLjAyOTkgMC4xMDg3NS0wLjA2MjUgMC4xNjI1LTAuMDkyNCA0Ljk2ODggMi4xMzcyIDcuODQgNC42NjExIDcuODQgNy4zNjYyIDAgNy42OTg4LTIzLjI2MSAxMy45NC01MS45NTUgMTMuOTQiIGZpbGw9IiNjZGNjY2EiLz4KICAgPHBhdGggaWQ9InBhdGg1ODA5IiBkPSJtMzE5Ljg5IDkxMy45M2MtMjguNTE2IDAtNTEuNjM0LTYuMjAyMS01MS42MzQtMTMuODU0IDAtMi42Njc1IDIuODExMi01LjE2MDEgNy42ODEyLTcuMjczOSAwLjA1NSAwLjAzMjYgMC4xMDc1IDAuMDYxNSAwLjE2MjUgMC4wOTM3LTQuNzcyNSAyLjA5MDItNy41MjM4IDQuNTQ4Ny03LjUyMzggNy4xODAxIDAgNy42MDM1IDIyLjk3NCAxMy43NjggNTEuMzE0IDEzLjc2OHM1MS4zMTItNi4xNjQxIDUxLjMxMi0xMy43NjhjMC0yLjYzMTQtMi43NTEyLTUuMDg5OS03LjUyMjUtNy4xODAxIDAuMDU1LTAuMDMyMyAwLjEwNzUtMC4wNjExIDAuMTYyNS0wLjA5MzcgNC44NyAyLjExNTEgNy42ODEyIDQuNjA2NCA3LjY4MTIgNy4yNzM5IDAgNy42NTE0LTIzLjExOCAxMy44NTQtNTEuNjM0IDEzLjg1NCIgZmlsbD0iI2NjY2JjOSIvPgogICA8cGF0aCBpZD0icGF0aDU4MTEiIGQ9Im0zMTkuODkgOTEzLjg1Yy0yOC4zNCAwLTUxLjMxNC02LjE2NDEtNTEuMzE0LTEzLjc2OCAwLTIuNjMxNCAyLjc1MTItNS4wODk5IDcuNTIzOC03LjE4MDEgMC4wNTM3IDAuMDMwMiAwLjExIDAuMDYyNSAwLjE2Mzc1IDAuMDkyNy00LjY3NSAyLjA2NzQtNy4zNjYyIDQuNDkyMS03LjM2NjIgNy4wODc0IDAgNy41NTYxIDIyLjgzIDEzLjY4MSA1MC45OTIgMTMuNjgxczUwLjk5Mi02LjEyNSA1MC45OTItMTMuNjgxYzAtMi41OTM4LTIuNjkyNS01LjAyLTcuMzY2Mi03LjA4NjQgMC4wNTM3LTAuMDMxMiAwLjExLTAuMDYzNSAwLjE2Mzc1LTAuMDkzNyA0Ljc3MTIgMi4wOTAyIDcuNTIyNSA0LjU0ODcgNy41MjI1IDcuMTgwMSAwIDcuNjAzNS0yMi45NzIgMTMuNzY4LTUxLjMxMiAxMy43NjgiIGZpbGw9IiNjYmM5YzgiLz4KICAgPHBhdGggaWQ9InBhdGg1ODEzIiBkPSJtMzE5Ljg5IDkxMy43NmMtMjguMTYyIDAtNTAuOTkyLTYuMTI1LTUwLjk5Mi0xMy42ODEgMC0yLjU5NTIgMi42OTEyLTUuMDIgNy4zNjYyLTcuMDg3NCAwLjA1NjIgMC4wMzIzIDAuMTA4NzUgMC4wNjI1IDAuMTY1IDAuMDkzNy00LjU3NzUgMi4wNDM0LTcuMjEgNC40MzYtNy4yMSA2Ljk5MzYgMCA3LjUwODggMjIuNjg2IDEzLjU5NSA1MC42NzEgMTMuNTk1czUwLjY3MS02LjA4NjUgNTAuNjcxLTEzLjU5NWMwLTIuNTU3Ni0yLjYzMjUtNC45NTAyLTcuMjEtNi45OTM2IDAuMDU2My0wLjAzMTIgMC4xMS0wLjA2MTUgMC4xNjUtMC4wOTI3IDQuNjczOCAyLjA2NjQgNy4zNjYyIDQuNDkyNiA3LjM2NjIgNy4wODY0IDAgNy41NTYxLTIyLjgzIDEzLjY4MS01MC45OTIgMTMuNjgxIiBmaWxsPSIjY2FjOGM3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTgxNSIgZD0ibTMxOS44OSA5MTMuNjhjLTI3Ljk4NSAwLTUwLjY3MS02LjA4NjUtNTAuNjcxLTEzLjU5NSAwLTIuNTU3NiAyLjYzMjUtNC45NTAyIDcuMjEtNi45OTM2IDAuMDU1IDAuMDMxMiAwLjEwODc1IDAuMDYyNSAwLjE2NSAwLjA5MzctNC40ODEyIDIuMDItNy4wNTUgNC4zNzg0LTcuMDU1IDYuODk5OSAwIDcuNDYxNCAyMi41NDIgMTMuNTA5IDUwLjM1MSAxMy41MDkgMjcuODA4IDAgNTAuMzUxLTYuMDQ3NCA1MC4zNTEtMTMuNTA5IDAtMi41MjE1LTIuNTczOC00Ljg3OTktNy4wNTUtNi44OTk5IDAuMDU2My0wLjAzMTIgMC4xMS0wLjA2MTUgMC4xNjUtMC4wOTM3IDQuNTc3NSAyLjA0MzQgNy4yMSA0LjQzNiA3LjIxIDYuOTkzNiAwIDcuNTA4OC0yMi42ODYgMTMuNTk1LTUwLjY3MSAxMy41OTUiIGZpbGw9IiNjOWM3YzYiLz4KICAgPHBhdGggaWQ9InBhdGg1ODE3IiBkPSJtMzE5Ljg5IDkxMy41OWMtMjcuODA5IDAtNTAuMzUxLTYuMDQ3NC01MC4zNTEtMTMuNTA5IDAtMi41MjE1IDIuNTczOC00Ljg3OTkgNy4wNTUtNi44OTk5IDAuMDU2MiAwLjAzMTIgMC4xMSAwLjA2MjUgMC4xNjYyNSAwLjA5MzctNC4zODUgMS45OTQ2LTYuOSA0LjMyMjItNi45IDYuODA2MSAwIDcuNDEzNiAyMi4zOTkgMTMuNDIyIDUwLjAzIDEzLjQyMnM1MC4wMy02LjAwODcgNTAuMDMtMTMuNDIyYzAtMi40ODM5LTIuNTE1LTQuODExNS02LjktNi44MDYxIDAuMDU2My0wLjAzMTIgMC4xMS0wLjA2MjUgMC4xNjYyNS0wLjA5MzcgNC40ODEyIDIuMDIgNy4wNTUgNC4zNzg0IDcuMDU1IDYuODk5OSAwIDcuNDYxNC0yMi41NDQgMTMuNTA5LTUwLjM1MSAxMy41MDkiIGZpbGw9IiNjOGM2YzUiLz4KICAgPHBhdGggaWQ9InBhdGg1ODE5IiBkPSJtMzE5Ljg5IDkxMy41Yy0yNy42MzEgMC01MC4wMy02LjAwODctNTAuMDMtMTMuNDIyIDAtMi40ODM5IDIuNTE1LTQuODExNSA2LjktNi44MDYxIDAuMDUzOCAwLjAyOTcgMC4xMTM3NSAwLjA2MzUgMC4xNjc1IDAuMDkzNy00LjI5IDEuOTcxMS02Ljc0NzUgNC4yNjQ2LTYuNzQ3NSA2LjcxMjQgMCA3LjM2NjIgMjIuMjU2IDEzLjMzNyA0OS43MSAxMy4zMzdzNDkuNzA5LTUuOTcxMiA0OS43MDktMTMuMzM3YzAtMi40NDc4LTIuNDU3NS00Ljc0MTItNi43NDYyLTYuNzEyNCAwLjA1NjItMC4wMzEyIDAuMTExMjUtMC4wNjI1IDAuMTY3NS0wLjA5MzcgNC4zODUgMS45OTQ2IDYuOSA0LjMyMjIgNi45IDYuODA2MSAwIDcuNDEzNi0yMi4zOTkgMTMuNDIyLTUwLjAzIDEzLjQyMiIgZmlsbD0iI2M3YzVjMyIvPgogICA8cGF0aCBpZD0icGF0aDU4MjEiIGQ9Im0zMTkuODkgOTEzLjQyYy0yNy40NTQgMC00OS43MS01Ljk3MTItNDkuNzEtMTMuMzM3IDAtMi40NDc4IDIuNDU3NS00Ljc0MTIgNi43NDc1LTYuNzEyNCAwLjA1ODcgMC4wMzIzIDAuMTEgMC4wNiAwLjE2ODc1IDAuMDkyMi00LjE5NSAxLjk0ODgtNi41OTUgNC4yMDktNi41OTUgNi42MjAxIDAgNy4zMTg5IDIyLjExMiAxMy4yNTEgNDkuMzg5IDEzLjI1MSAyNy4yNzYgMCA0OS4zODktNS45MzI2IDQ5LjM4OS0xMy4yNTEgMC0yLjQxMTEtMi40LTQuNjcxNC02LjU5NS02LjYxODYgMC4wNTYzLTAuMDMxMiAwLjExMjUtMC4wNjI1IDAuMTY4NzUtMC4wOTM3IDQuMjg4OCAxLjk3MTEgNi43NDYyIDQuMjY0NiA2Ljc0NjIgNi43MTI0IDAgNy4zNjYyLTIyLjI1NSAxMy4zMzctNDkuNzA5IDEzLjMzNyIgZmlsbD0iI2M2YzRjMiIvPgogICA8cGF0aCBpZD0icGF0aDU4MjMiIGQ9Im0zMTkuODkgOTEzLjMzYy0yNy4yNzYgMC00OS4zODktNS45MzI2LTQ5LjM4OS0xMy4yNTEgMC0yLjQxMTEgMi40LTQuNjcxNCA2LjU5NS02LjYyMDEgMC4wNTYyIDAuMDMxMiAwLjExMjUgMC4wNjQgMC4xNjg3NSAwLjA5MzctNC4xIDEuOTIzOS02LjQ0MjUgNC4xNTE0LTYuNDQyNSA2LjUyNjQgMCA3LjI3MSAyMS45NjggMTMuMTY1IDQ5LjA2OCAxMy4xNjVzNDkuMDY4LTUuODk0IDQ5LjA2OC0xMy4xNjVjMC0yLjM3NS0yLjM0MjUtNC42MDI1LTYuNDQyNS02LjUyNjQgMC4wNTYzLTAuMDI5NyAwLjExMjUtMC4wNjI1IDAuMTY4NzUtMC4wOTIyIDQuMTk1IDEuOTQ3MiA2LjU5NSA0LjIwNzUgNi41OTUgNi42MTg2IDAgNy4zMTg5LTIyLjExMiAxMy4yNTEtNDkuMzg5IDEzLjI1MSIgZmlsbD0iI2M0YzNjMSIvPgogICA8cGF0aCBpZD0icGF0aDU4MjUiIGQ9Im0zMTkuODkgOTEzLjI1Yy0yNy4xIDAtNDkuMDY4LTUuODk0LTQ5LjA2OC0xMy4xNjUgMC0yLjM3NSAyLjM0MjUtNC42MDI1IDYuNDQyNS02LjUyNjQgMC4wNTYzIDAuMDMxMiAwLjExNSAwLjA2NCAwLjE3MTI1IDAuMDkzNy00LjAwNjIgMS44OTk5LTYuMjkzOCA0LjA5NTMtNi4yOTM4IDYuNDMyNiAwIDcuMjIzNiAyMS44MjUgMTMuMDc5IDQ4Ljc0OCAxMy4wNzkgMjYuOTIyIDAgNDguNzQ4LTUuODU1IDQ4Ljc0OC0xMy4wNzkgMC0yLjMzNzQtMi4yODc1LTQuNTMyOC02LjI5MzgtNi40MzI2IDAuMDU2My0wLjAyOTcgMC4xMTUtMC4wNjI1IDAuMTcxMjUtMC4wOTM3IDQuMSAxLjkyMzkgNi40NDI1IDQuMTUxNCA2LjQ0MjUgNi41MjY0IDAgNy4yNzEtMjEuOTY4IDEzLjE2NS00OS4wNjggMTMuMTY1IiBmaWxsPSIjYzNjMmMwIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgyNyIgZD0ibTMxOS44OSA5MTMuMTZjLTI2LjkyMiAwLTQ4Ljc0OC01Ljg1NS00OC43NDgtMTMuMDc5IDAtMi4zMzc0IDIuMjg3NS00LjUzMjggNi4yOTM4LTYuNDMyNiAwLjA1NjMgMC4wMzEyIDAuMTE1IDAuMDY0IDAuMTcxMjUgMC4wOTM3LTMuOTEyNSAxLjg3NS02LjE0MzggNC4wMzc2LTYuMTQzOCA2LjMzODkgMCA3LjE3NjIgMjEuNjgxIDEyLjk5MyA0OC40MjYgMTIuOTkzczQ4LjQyNi01LjgxNjQgNDguNDI2LTEyLjk5M2MwLTIuMzAxMi0yLjIzMTItNC40NjM5LTYuMTQzOC02LjMzODkgMC4wNTYzLTAuMDI5NyAwLjExNS0wLjA2MjUgMC4xNzEyNS0wLjA5MzcgNC4wMDYyIDEuODk5OSA2LjI5MzggNC4wOTUzIDYuMjkzOCA2LjQzMjYgMCA3LjIyMzYtMjEuODI1IDEzLjA3OS00OC43NDggMTMuMDc5IiBmaWxsPSIjYzJjMWJmIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgyOSIgZD0ibTMxOS44OSA5MTMuMDdjLTI2Ljc0NSAwLTQ4LjQyNi01LjgxNjQtNDguNDI2LTEyLjk5MyAwLTIuMzAxMiAyLjIzMTItNC40NjM5IDYuMTQzOC02LjMzODkgMC4wNTg3IDAuMDMyOCAwLjExMzc1IDAuMDYyNSAwLjE3MjUgMC4wOTM3LTMuODIxMiAxLjg1MTYtNS45OTYyIDMuOTgtNS45OTYyIDYuMjQ1MSAwIDcuMTI4OSAyMS41MzkgMTIuOTA2IDQ4LjEwNiAxMi45MDYgMjYuNTY4IDAgNDguMTA1LTUuNzc3NCA0OC4xMDUtMTIuOTA2IDAtMi4yNjUxLTIuMTc1LTQuMzkzNS01Ljk5NS02LjI0MzYgMC4wNTg4LTAuMDMyOCAwLjExMzc1LTAuMDYyNSAwLjE3MjUtMC4wOTUzIDMuOTEyNSAxLjg3NSA2LjE0MzggNC4wMzc2IDYuMTQzOCA2LjMzODkgMCA3LjE3NjItMjEuNjgxIDEyLjk5My00OC40MjYgMTIuOTkzIiBmaWxsPSIjYzFjMGJlIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgzMSIgZD0ibTMxOS44OSA5MTIuOTljLTI2LjU2OCAwLTQ4LjEwNi01Ljc3NzQtNDguMTA2LTEyLjkwNiAwLTIuMjY1MSAyLjE3NS00LjM5MzUgNS45OTYyLTYuMjQ1MSAwLjA1NjMgMC4wMzEyIDAuMTE2MjUgMC4wNjQgMC4xNzI1IDAuMDk1My0zLjcyNzUgMS44MjQ2LTUuODQ3NSAzLjkyMTQtNS44NDc1IDYuMTQ5OSAwIDcuMDgxIDIxLjM5NCAxMi44MiA0Ny43ODUgMTIuODJzNDcuNzg1LTUuNzM4OCA0Ny43ODUtMTIuODJjMC0yLjIyODUtMi4xMi00LjMyNTItNS44NDc1LTYuMTQ5OSAwLjA1NjMtMC4wMzEyIDAuMTE2MjUtMC4wNjQgMC4xNzI1LTAuMDkzNyAzLjgyIDEuODUwMSA1Ljk5NSAzLjk3ODUgNS45OTUgNi4yNDM2IDAgNy4xMjg5LTIxLjUzOCAxMi45MDYtNDguMTA1IDEyLjkwNiIgZmlsbD0iI2MwYmZiZCIvPgogICA8cGF0aCBpZD0icGF0aDU4MzMiIGQ9Im0zMTkuODkgOTEyLjljLTI2LjM5MSAwLTQ3Ljc4NS01LjczODgtNDcuNzg1LTEyLjgyIDAtMi4yMjg1IDIuMTItNC4zMjUyIDUuODQ3NS02LjE0OTkgMC4wNTg4IDAuMDMxMiAwLjExNjI1IDAuMDYxIDAuMTc1IDAuMDkzNy0zLjYzNjIgMS44MDEyLTUuNzAxMiAzLjg2MzgtNS43MDEyIDYuMDU2MSAwIDcuMDMyOCAyMS4yNSAxMi43MzUgNDcuNDY0IDEyLjczNXM0Ny40NjQtNS43MDIxIDQ3LjQ2NC0xMi43MzVjMC0yLjE5MjQtMi4wNjUtNC4yNTQ5LTUuNzAxMi02LjA1NjEgMC4wNTg4LTAuMDMxMiAwLjExNjI1LTAuMDYyNSAwLjE3NS0wLjA5MzcgMy43Mjc1IDEuODI0NiA1Ljg0NzUgMy45MjE0IDUuODQ3NSA2LjE0OTkgMCA3LjA4MS0yMS4zOTQgMTIuODItNDcuNzg1IDEyLjgyIiBmaWxsPSIjYmZiZWJjIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTgzNSIgZD0ibTMxOS44OSA5MTIuODFjLTI2LjIxNCAwLTQ3LjQ2NC01LjcwMjEtNDcuNDY0LTEyLjczNSAwLTIuMTkyNCAyLjA2NS00LjI1NDkgNS43MDEyLTYuMDU2MSAwLjA1ODcgMC4wMzEyIDAuMTE2MjUgMC4wNjI1IDAuMTc1IDAuMDkzNy0zLjU0NSAxLjc3NTktNS41NTYyIDMuODA2MS01LjU1NjIgNS45NjI0IDAgNi45ODQ5IDIxLjEwOCAxMi42NDkgNDcuMTQ0IDEyLjY0OXM0Ny4xNDItNS42NjQgNDcuMTQyLTEyLjY0OWMwLTIuMTU2Mi0yLjAxLTQuMTg2NS01LjU1NS01Ljk2MjQgMC4wNTg3LTAuMDMxMiAwLjExNjI1LTAuMDYyNSAwLjE3NS0wLjA5MzcgMy42MzYyIDEuODAxMiA1LjcwMTIgMy44NjM4IDUuNzAxMiA2LjA1NjEgMCA3LjAzMjgtMjEuMjUgMTIuNzM1LTQ3LjQ2NCAxMi43MzUiIGZpbGw9IiNiZWJkYmIiLz4KICAgPHBhdGggaWQ9InBhdGg1ODM3IiBkPSJtMzE5Ljg5IDkxMi43M2MtMjYuMDM2IDAtNDcuMTQ0LTUuNjY0LTQ3LjE0NC0xMi42NDkgMC0yLjE1NjIgMi4wMTEyLTQuMTg2NSA1LjU1NjItNS45NjI0IDAuMDYgMC4wMzEyIDAuMTE3NSAwLjA2MjUgMC4xNzc1IDAuMDkzNy0zLjQ1NjIgMS43NTI0LTUuNDEyNSAzLjc0ODUtNS40MTI1IDUuODY4NiAwIDYuOTM3NSAyMC45NjIgMTIuNTYyIDQ2LjgyMiAxMi41NjIgMjUuODU5IDAgNDYuODIyLTUuNjI1IDQ2LjgyMi0xMi41NjIgMC0yLjEyMDEtMS45NTYyLTQuMTE2Mi01LjQxMjUtNS44Njc2IDAuMDYtMC4wMzIzIDAuMTE3NS0wLjA2MzUgMC4xNzc1LTAuMDk0OCAzLjU0NSAxLjc3NTkgNS41NTUgMy44MDYxIDUuNTU1IDUuOTYyNCAwIDYuOTg0OS0yMS4xMDYgMTIuNjQ5LTQ3LjE0MiAxMi42NDkiIGZpbGw9IiNiZGJjYmEiLz4KICAgPHBhdGggaWQ9InBhdGg1ODM5IiBkPSJtMzE5Ljg5IDkxMi42NGMtMjUuODYgMC00Ni44MjItNS42MjUtNDYuODIyLTEyLjU2MiAwLTIuMTIwMSAxLjk1NjItNC4xMTYyIDUuNDEyNS01Ljg2ODYgMC4wNTg3IDAuMDMyMyAwLjExODc1IDAuMDYzNSAwLjE3NzUgMC4wOTQ4LTMuMzY2MiAxLjcyNjUtNS4yNyAzLjY4OTktNS4yNyA1Ljc3MzkgMCA2Ljg5MDEgMjAuODIgMTIuNDc2IDQ2LjUwMiAxMi40NzZzNDYuNTAxLTUuNTg2IDQ2LjUwMS0xMi40NzZjMC0yLjA4NC0xLjkwMjUtNC4wNDc0LTUuMjY4OC01Ljc3MzkgMC4wNTg4LTAuMDMxMiAwLjExODc1LTAuMDYyNSAwLjE3NzUtMC4wOTM3IDMuNDU2MiAxLjc1MTQgNS40MTI1IDMuNzQ3NSA1LjQxMjUgNS44Njc2IDAgNi45Mzc1LTIwLjk2NCAxMi41NjItNDYuODIyIDEyLjU2MiIgZmlsbD0iI2JjYmJiOSIvPgogICA8cGF0aCBpZD0icGF0aDU4NDEiIGQ9Im0zMTkuODkgOTEyLjU2Yy0yNS42ODIgMC00Ni41MDItNS41ODYtNDYuNTAyLTEyLjQ3NiAwLTIuMDg0IDEuOTAzOC00LjA0NzQgNS4yNy01Ljc3MzkgMC4wNTg3IDAuMDMxMiAwLjEyIDAuMDYyNSAwLjE3ODc1IDAuMDkzNy0zLjI3NzUgMS43MDExLTUuMTI3NSAzLjYzMjgtNS4xMjc1IDUuNjgwMSAwIDYuODQyMiAyMC42NzYgMTIuMzkgNDYuMTgxIDEyLjM5czQ2LjE4MS01LjU0NzkgNDYuMTgxLTEyLjM5YzAtMi4wNDY0LTEuODUxMi0zLjk3OS01LjEyNzUtNS42ODAxIDAuMDU4OC0wLjAzMTIgMC4xMi0wLjA2MjUgMC4xNzg3NS0wLjA5MzcgMy4zNjYyIDEuNzI2NSA1LjI2ODggMy42ODk5IDUuMjY4OCA1Ljc3MzkgMCA2Ljg5MDEtMjAuODE5IDEyLjQ3Ni00Ni41MDEgMTIuNDc2IiBmaWxsPSIjYmJiYWI4Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTg0MyIgZD0ibTMxOS44OSA5MTIuNDdjLTI1LjUwNSAwLTQ2LjE4MS01LjU0NzktNDYuMTgxLTEyLjM5IDAtMi4wNDc0IDEuODUtMy45NzkgNS4xMjc1LTUuNjgwMSAwLjA1ODcgMC4wMzEyIDAuMTIgMC4wNjM5IDAuMTggMC4wOTUxLTMuMTg4OCAxLjY3NjItNC45ODYyIDMuNTczOC00Ljk4NjIgNS41ODUgMCA2Ljc5NDkgMjAuNTMyIDEyLjMwNCA0NS44NiAxMi4zMDRzNDUuODYtNS41MDg4IDQ1Ljg2LTEyLjMwNGMwLTIuMDA5OC0xLjc5ODgtMy45MDg4LTQuOTg2Mi01LjU4NSAwLjA2LTAuMDMxMiAwLjEyMTI1LTAuMDYzOSAwLjE4LTAuMDk1MSAzLjI3NjIgMS43MDExIDUuMTI3NSAzLjYzMzcgNS4xMjc1IDUuNjgwMSAwIDYuODQyMi0yMC42NzYgMTIuMzktNDYuMTgxIDEyLjM5IiBmaWxsPSIjYmFiOWI3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTg0NSIgZD0ibTMxOS44OSA5MTIuMzhjLTI1LjMyOCAwLTQ1Ljg2LTUuNTA4OC00NS44Ni0xMi4zMDQgMC0yLjAxMTIgMS43OTc1LTMuOTA4OCA0Ljk4NjItNS41ODUgMC4wNjEyIDAuMDMyNyAwLjExODc1IDAuMDYyNSAwLjE4MTI1IDAuMDkzNy0zLjEwMTIgMS42NTE0LTQuODQ3NSAzLjUxNjEtNC44NDc1IDUuNDkxMiAwIDYuNzQ3NSAyMC4zODkgMTIuMjE5IDQ1LjU0IDEyLjIxOSAyNS4xNSAwIDQ1LjUzOS01LjQ3MTIgNDUuNTM5LTEyLjIxOSAwLTEuOTc1MS0xLjc0NS0zLjgzOTktNC44NDYyLTUuNDkxMiAwLjA2MjUtMC4wMzEyIDAuMTItMC4wNjEgMC4xODEyNS0wLjA5MzcgMy4xODc1IDEuNjc2MiA0Ljk4NjIgMy41NzUyIDQuOTg2MiA1LjU4NSAwIDYuNzk0OS0yMC41MzIgMTIuMzA0LTQ1Ljg2IDEyLjMwNCIgZmlsbD0iI2I5YjhiNiIvPgogICA8cGF0aCBpZD0icGF0aDU4NDciIGQ9Im0zMTkuODkgOTEyLjNjLTI1LjE1MSAwLTQ1LjU0LTUuNDcxMi00NS41NC0xMi4yMTkgMC0xLjk3NTEgMS43NDYyLTMuODM5OSA0Ljg0NzUtNS40OTEyIDAuMDU4OCAwLjAzMTIgMC4xMjI1IDAuMDYzNSAwLjE4MjUgMC4wOTUyLTMuMDEzOCAxLjYyNDUtNC43MDg4IDMuNDU3LTQuNzA4OCA1LjM5NiAwIDYuNzAwMiAyMC4yNDUgMTIuMTMyIDQ1LjIxOSAxMi4xMzJzNDUuMjE5LTUuNDMyMSA0NS4yMTktMTIuMTMyYzAtMS45MzktMS42OTUtMy43NzE1LTQuNzA4OC01LjM5NiAwLjA2LTAuMDMxNyAwLjEyMzc1LTAuMDY0IDAuMTgyNS0wLjA5NTIgMy4xMDEyIDEuNjUxNCA0Ljg0NjIgMy41MTYxIDQuODQ2MiA1LjQ5MTIgMCA2Ljc0NzUtMjAuMzg5IDEyLjIxOS00NS41MzkgMTIuMjE5IiBmaWxsPSIjYjdiN2I1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTg0OSIgZD0ibTMxOS44OSA5MTIuMjFjLTI0Ljk3NCAwLTQ1LjIxOS01LjQzMjEtNDUuMjE5LTEyLjEzMiAwLTEuOTM5IDEuNjk1LTMuNzcxNSA0LjcwODgtNS4zOTYgMC4wNjEzIDAuMDMyMyAwLjEyMTI1IDAuMDYyIDAuMTgzNzUgMC4wOTQ4LTIuOTI3NSAxLjU5ODYtNC41NzI1IDMuMzk4OS00LjU3MjUgNS4zMDEyIDAgNi42NTI0IDIwLjEwMiAxMi4wNDYgNDQuODk5IDEyLjA0NiAyNC43OTYgMCA0NC44OTgtNS4zOTQgNDQuODk4LTEyLjA0NiAwLTEuOTAyNC0xLjY0MzgtMy43MDI2LTQuNTcxMi01LjMwMTIgMC4wNjI1LTAuMDMyOCAwLjEyMjUtMC4wNjI1IDAuMTgzNzUtMC4wOTQ4IDMuMDEzOCAxLjYyNDUgNC43MDg4IDMuNDU3IDQuNzA4OCA1LjM5NiAwIDYuNzAwMi0yMC4yNDUgMTIuMTMyLTQ1LjIxOSAxMi4xMzIiIGZpbGw9IiNiNmI2YjQiLz4KICAgPHBhdGggaWQ9InBhdGg1ODUxIiBkPSJtMzE5Ljg5IDkxMi4xM2MtMjQuNzk2IDAtNDQuODk5LTUuMzk0LTQ0Ljg5OS0xMi4wNDYgMC0xLjkwMjQgMS42NDUtMy43MDI2IDQuNTcyNS01LjMwMTIgMC4wNjEyIDAuMDMxMiAwLjEyMjUgMC4wNjI1IDAuMTg1IDAuMDkzNy0yLjg0MjUgMS41NzUxLTQuNDM2MiAzLjM0MTItNC40MzYyIDUuMjA3NSAwIDYuNjA1IDE5Ljk1OCAxMS45NiA0NC41NzggMTEuOTYgMjQuNjE5IDAgNDQuNTc4LTUuMzU1IDQ0LjU3OC0xMS45NiAwLTEuODY2Mi0xLjU5NS0zLjYzMjQtNC40MzYyLTUuMjA3NSAwLjA2MjUtMC4wMzEyIDAuMTIzNzUtMC4wNjI1IDAuMTg1LTAuMDkzNyAyLjkyNzUgMS41OTg2IDQuNTcxMiAzLjM5ODkgNC41NzEyIDUuMzAxMiAwIDYuNjUyNC0yMC4xMDEgMTIuMDQ2LTQ0Ljg5OCAxMi4wNDYiIGZpbGw9IiNiNWI0YjMiLz4KICAgPHBhdGggaWQ9InBhdGg1ODUzIiBkPSJtMzE5Ljg5IDkxMi4wNGMtMjQuNjIgMC00NC41NzgtNS4zNTUtNDQuNTc4LTExLjk2IDAtMS44NjYyIDEuNTkzOC0zLjYzMjQgNC40MzYyLTUuMjA3NSAwLjA1ODggMC4wMzEyIDAuMTI2MjUgMC4wNjQ5IDAuMTg1IDAuMDk1Mi0yLjc1NjIgMS41NDgyLTQuMyAzLjI4MjEtNC4zIDUuMTEyMiAwIDYuNTU3NiAxOS44MTQgMTEuODc0IDQ0LjI1NiAxMS44NzRzNDQuMjU2LTUuMzE1OSA0NC4yNTYtMTEuODc0YzAtMS44MzAxLTEuNTQzOC0zLjU2NC00LjMtNS4xMTIyIDAuMDYxMy0wLjAzMTIgMC4xMjM3NS0wLjA2MjUgMC4xODUtMC4wOTUyIDIuODQxMiAxLjU3NTEgNC40MzYyIDMuMzQxMiA0LjQzNjIgNS4yMDc1IDAgNi42MDUtMTkuOTU5IDExLjk2LTQ0LjU3OCAxMS45NiIgZmlsbD0iI2I0YjNiMiIvPgogICA8cGF0aCBpZD0icGF0aDU4NTUiIGQ9Im0zMTkuODkgOTExLjk1Yy0yNC40NDIgMC00NC4yNTYtNS4zMTU5LTQ0LjI1Ni0xMS44NzQgMC0xLjgzMDEgMS41NDM4LTMuNTY0IDQuMy01LjExMjIgMC4wNjUgMC4wMzM2IDAuMTIyNSAwLjA2MjUgMC4xODc1IDAuMDk0Ni0yLjY3MjUgMS41MjI1LTQuMTY3NSAzLjIyMzYtNC4xNjc1IDUuMDE3NiAwIDYuNTA5OCAxOS42NzEgMTEuNzg4IDQzLjkzNiAxMS43ODhzNDMuOTM1LTUuMjc3OCA0My45MzUtMTEuNzg4YzAtMS43OTQtMS40OTM4LTMuNDk1MS00LjE2NjItNS4wMTc2IDAuMDYyNS0wLjAzMTIgMC4xMjUtMC4wNjI1IDAuMTg3NS0wLjA5NDYgMi43NTYyIDEuNTQ4MiA0LjMgMy4yODIxIDQuMyA1LjExMjIgMCA2LjU1NzYtMTkuODE0IDExLjg3NC00NC4yNTYgMTEuODc0IiBmaWxsPSIjYjNiMmIxIi8+CiAgIDxwYXRoIGlkPSJwYXRoNTg1NyIgZD0ibTMxOS44OSA5MTEuODdjLTI0LjI2NSAwLTQzLjkzNi01LjI3NzgtNDMuOTM2LTExLjc4OCAwLTEuNzk0IDEuNDk1LTMuNDk1MSA0LjE2NzUtNS4wMTc2IDAuMDYyNSAwLjAzMTIgMC4xMjYyNSAwLjA2NCAwLjE4ODc1IDAuMDk1My0yLjU5IDEuNDk2MS00LjAzNSAzLjE2MzYtNC4wMzUgNC45MjI0IDAgNi40NjI0IDE5LjUyOCAxMS43MDEgNDMuNjE1IDExLjcwMSAyNC4wODggMCA0My42MTUtNS4yMzg4IDQzLjYxNS0xMS43MDEgMC0xLjc1ODgtMS40NDUtMy40MjYyLTQuMDM1LTQuOTIyNCAwLjA2MjUtMC4wMzEyIDAuMTI2MjUtMC4wNjQgMC4xODg3NS0wLjA5NTMgMi42NzI1IDEuNTIyNSA0LjE2NjIgMy4yMjM2IDQuMTY2MiA1LjAxNzYgMCA2LjUwOTgtMTkuNjcgMTEuNzg4LTQzLjkzNSAxMS43ODgiIGZpbGw9IiNiMWIxYjAiLz4KICAgPHBhdGggaWQ9InBhdGg1ODU5IiBkPSJtMzE5Ljg5IDkxMS43OGMtMjQuMDg4IDAtNDMuNjE1LTUuMjM4OC00My42MTUtMTEuNzAxIDAtMS43NTg4IDEuNDQ1LTMuNDI2MiA0LjAzNS00LjkyMjQgMC4wNjI1IDAuMDMxMiAwLjEyNzUgMC4wNjM1IDAuMTkgMC4wOTQ3LTIuNTA3NSAxLjQ3MDItMy45MDM4IDMuMTA1LTMuOTAzOCA0LjgyNzYgMCA2LjQxNSAxOS4zODIgMTEuNjE2IDQzLjI5NCAxMS42MTYgMjMuOTEgMCA0My4yOTQtNS4yMDEyIDQzLjI5NC0xMS42MTYgMC0xLjcyMjYtMS4zOTYyLTMuMzU3NC0zLjkwMzgtNC44Mjc2IDAuMDYyNS0wLjAzMTIgMC4xMjc1LTAuMDYzNSAwLjE5LTAuMDk0NyAyLjU5IDEuNDk2MSA0LjAzNSAzLjE2MzYgNC4wMzUgNC45MjI0IDAgNi40NjI0LTE5LjUyOCAxMS43MDEtNDMuNjE1IDExLjcwMSIgZmlsbD0iI2IwYjBhZiIvPgogICA8cGF0aCBpZD0icGF0aDU4NjEiIGQ9Im0zMTkuODkgOTExLjdjLTIzLjkxMSAwLTQzLjI5NC01LjIwMTItNDMuMjk0LTExLjYxNiAwLTEuNzIyNiAxLjM5NjItMy4zNTc0IDMuOTAzOC00LjgyNzYgMC4wNjUgMC4wMzI3IDAuMTI2MjUgMC4wNjI1IDAuMTkxMjUgMC4wOTUyLTIuNDI1IDEuNDQzOC0zLjc3NSAzLjA0NTktMy43NzUgNC43MzI0IDAgNi4zNjc2IDE5LjI0IDExLjUzIDQyLjk3NCAxMS41M3M0Mi45NzQtNS4xNjIyIDQyLjk3NC0xMS41M2MwLTEuNjg2NS0xLjM1LTMuMjg4Ni0zLjc3NS00LjczMjQgMC4wNjUtMC4wMzEyIDAuMTI2MjUtMC4wNjI1IDAuMTkxMjUtMC4wOTUyIDIuNTA3NSAxLjQ3MDIgMy45MDM4IDMuMTA1IDMuOTAzOCA0LjgyNzYgMCA2LjQxNS0xOS4zODQgMTEuNjE2LTQzLjI5NCAxMS42MTYiIGZpbGw9IiNhZmFmYWQiLz4KICAgPHBhdGggaWQ9InBhdGg1ODYzIiBkPSJtMzE5Ljg5IDkxMS42MWMtMjMuNzM0IDAtNDIuOTc0LTUuMTYyMi00Mi45NzQtMTEuNTMgMC0xLjY4NjUgMS4zNS0zLjI4ODYgMy43NzUtNC43MzI0IDAuMDYyNSAwLjAzMTIgMC4xMyAwLjA2NDkgMC4xOTI1IDAuMDk2MS0yLjM0MzggMS40MTYtMy42NDYyIDIuOTg2NC0zLjY0NjIgNC42MzYyIDAgNi4zMTk5IDE5LjA5NiAxMS40NDQgNDIuNjUyIDExLjQ0NHM0Mi42NTItNS4xMjQgNDIuNjUyLTExLjQ0NGMwLTEuNjQ5OS0xLjMwMjUtMy4yMTg4LTMuNjQ2Mi00LjYzNjIgMC4wNjI1LTAuMDMxMiAwLjEzLTAuMDY0OSAwLjE5MjUtMC4wOTYxIDIuNDI1IDEuNDQzOCAzLjc3NSAzLjA0NTkgMy43NzUgNC43MzI0IDAgNi4zNjc2LTE5LjI0IDExLjUzLTQyLjk3NCAxMS41MyIgZmlsbD0iI2FlYWVhYyIvPgogICA8cGF0aCBpZD0icGF0aDU4NjUiIGQ9Im0zMTkuODkgOTExLjUyYy0yMy41NTYgMC00Mi42NTItNS4xMjQtNDIuNjUyLTExLjQ0NCAwLTEuNjQ5OSAxLjMwMjUtMy4yMjAyIDMuNjQ2Mi00LjYzNjIgMC4wNjYyIDAuMDMxMiAwLjEyODc1IDAuMDYyNSAwLjE5NSAwLjA5NDctMi4yNjUgMS4zOTE2LTMuNTIxMiAyLjkyNjItMy41MjEyIDQuNTQxNSAwIDYuMjcyNSAxOC45NTIgMTEuMzU3IDQyLjMzMiAxMS4zNTcgMjMuMzc5IDAgNDIuMzMxLTUuMDg0OSA0Mi4zMzEtMTEuMzU3IDAtMS42MTUyLTEuMjU1LTMuMTQ5OS0zLjUyLTQuNTQxNSAwLjA2NjMtMC4wMzIyIDAuMTI4NzUtMC4wNjM1IDAuMTk1LTAuMDk0NyAyLjM0MzggMS40MTc1IDMuNjQ2MiAyLjk4NjQgMy42NDYyIDQuNjM2MiAwIDYuMzE5OS0xOS4wOTYgMTEuNDQ0LTQyLjY1MiAxMS40NDQiIGZpbGw9IiNhZGFjYWIiLz4KICAgPHBhdGggaWQ9InBhdGg1ODY3IiBkPSJtMzE5Ljg5IDkxMS40NGMtMjMuMzggMC00Mi4zMzItNS4wODQ5LTQyLjMzMi0xMS4zNTcgMC0xLjYxNTIgMS4yNTYyLTMuMTQ5OSAzLjUyMTItNC41NDE1IDAuMDY1IDAuMDMxNyAwLjEzIDAuMDY0IDAuMTk1IDAuMDk1Mi0yLjE4NSAxLjM2NTItMy4zOTUgMi44Njc2LTMuMzk1IDQuNDQ2MiAwIDYuMjI1MSAxOC44MDkgMTEuMjcxIDQyLjAxMSAxMS4yNzEgMjMuMjAxIDAgNDIuMDExLTUuMDQ2NCA0Mi4wMTEtMTEuMjcxIDAtMS41Nzg2LTEuMjEtMy4wODEtMy4zOTUtNC40NDYyIDAuMDY1LTAuMDMxMiAwLjEzLTAuMDYzNSAwLjE5NS0wLjA5NTIgMi4yNjUgMS4zOTE2IDMuNTIgMi45MjYyIDMuNTIgNC41NDE1IDAgNi4yNzI1LTE4Ljk1MiAxMS4zNTctNDIuMzMxIDExLjM1NyIgZmlsbD0iI2FjYWJhYSIvPgogICA8cGF0aCBpZD0icGF0aDU4NjkiIGQ9Im0zMTkuODkgOTExLjM1Yy0yMy4yMDIgMC00Mi4wMTEtNS4wNDY0LTQyLjAxMS0xMS4yNzEgMC0xLjU3ODYgMS4yMS0zLjA4MSAzLjM5NS00LjQ0NjIgMC4wNjUgMC4wMzI2IDAuMTMxMjUgMC4wNjM5IDAuMTk3NSAwLjA5NjEtMi4xMDc1IDEuMzM3NC0zLjI3MTIgMi44MDc2LTMuMjcxMiA0LjM1MDEgMCA2LjE3NzIgMTguNjY1IDExLjE4NSA0MS42OSAxMS4xODVzNDEuNjktNS4wMDc4IDQxLjY5LTExLjE4NWMwLTEuNTQyNS0xLjE2MzgtMy4wMTI4LTMuMjcxMi00LjM1MDEgMC4wNjYyLTAuMDMyMiAwLjEzMjUtMC4wNjM1IDAuMTk3NS0wLjA5NjEgMi4xODUgMS4zNjUyIDMuMzk1IDIuODY3NiAzLjM5NSA0LjQ0NjIgMCA2LjIyNTEtMTguODEgMTEuMjcxLTQyLjAxMSAxMS4yNzEiIGZpbGw9IiNhYWFhYTkiLz4KICAgPHBhdGggaWQ9InBhdGg1ODcxIiBkPSJtMzE5Ljg5IDkxMS4yN2MtMjMuMDI1IDAtNDEuNjktNS4wMDc4LTQxLjY5LTExLjE4NSAwLTEuNTQyNSAxLjE2MzgtMy4wMTI4IDMuMjcxMi00LjM1MDEgMC4wNjUgMC4wMzEyIDAuMTMyNSAwLjA2NCAwLjE5NzUgMC4wOTUzLTIuMDI4OCAxLjMxMS0zLjE0ODggMi43NDc1LTMuMTQ4OCA0LjI1NDkgMCA2LjEyOTkgMTguNTIyIDExLjA5OSA0MS4zNyAxMS4wOTlzNDEuMzctNC45Njg4IDQxLjM3LTExLjA5OWMwLTEuNTA3NC0xLjEyLTIuOTQzOS0zLjE0ODgtNC4yNTQ5IDAuMDY1LTAuMDMxMiAwLjEzMjUtMC4wNjQgMC4xOTc1LTAuMDk1MyAyLjEwNzUgMS4zMzc0IDMuMjcxMiAyLjgwNzYgMy4yNzEyIDQuMzUwMSAwIDYuMTc3Mi0xOC42NjUgMTEuMTg1LTQxLjY5IDExLjE4NSIgZmlsbD0iI2E5YTlhOCIvPgogICA8cGF0aCBpZD0icGF0aDU4NzMiIGQ9Im0zMTkuODkgOTExLjE4Yy0yMi44NDggMC00MS4zNy00Ljk2ODgtNDEuMzctMTEuMDk5IDAtMS41MDc0IDEuMTItMi45NDM5IDMuMTQ4OC00LjI1NDkgMC4wNjg4IDAuMDMyMiAwLjEzMjUgMC4wNjM1IDAuMiAwLjA5NjEtMS45NTEyIDEuMjgyMi0zLjAyNzUgMi42ODc1LTMuMDI3NSA0LjE1ODggMCA2LjA4MjUgMTguMzc4IDExLjAxNCA0MS4wNDkgMTEuMDE0IDIyLjY3IDAgNDEuMDQ5LTQuOTMxMSA0MS4wNDktMTEuMDE0IDAtMS40NzEyLTEuMDc2Mi0yLjg3NS0zLjAyNzUtNC4xNTg4IDAuMDY3NS0wLjAzMjYgMC4xMzEyNS0wLjA2MzkgMC4yLTAuMDk2MSAyLjAyODggMS4zMTEgMy4xNDg4IDIuNzQ3NSAzLjE0ODggNC4yNTQ5IDAgNi4xMjk5LTE4LjUyMiAxMS4wOTktNDEuMzcgMTEuMDk5IiBmaWxsPSIjYThhOGE3Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTg3NSIgZD0ibTMxOS44OSA5MTEuMDljLTIyLjY3MSAwLTQxLjA0OS00LjkzMTEtNDEuMDQ5LTExLjAxNCAwLTEuNDcxMiAxLjA3NjItMi44NzY1IDMuMDI3NS00LjE1ODggMC4wNjYzIDAuMDMxMiAwLjEzNzUgMC4wNjUgMC4yMDI1IDAuMDk2My0xLjg3NSAxLjI1NDktMi45MDYyIDIuNjI1LTIuOTA2MiA0LjA1ODYgMCA2LjA0MTUgMTguMjM2IDEwLjkzNSA0MC43MjUgMTAuOTM1czQwLjcyNS00Ljg5MzUgNDAuNzI1LTEwLjkzNWMwLTEuNDMzNi0xLjAzMTItMi44MDM4LTIuOTA2Mi00LjA1ODYgMC4wNjYyLTAuMDMxMiAwLjEzNjI1LTAuMDY1IDAuMjAyNS0wLjA5NjMgMS45NTEyIDEuMjgzOCAzLjAyNzUgMi42ODc1IDMuMDI3NSA0LjE1ODggMCA2LjA4MjUtMTguMzc5IDExLjAxNC00MS4wNDkgMTEuMDE0IiBmaWxsPSIjYTdhNmE1Ii8+CiAgIDxwYXRoIGlkPSJwYXRoNTg3NyIgZD0ibTMyNi42MiA5MDAuMDhjMCAwLjk5NzUtMy4wMTI1IDEuODA1MS02LjcyNzUgMS44MDUxcy02LjcyNzUtMC44MDc2Mi02LjcyNzUtMS44MDUxYzAtMC45OTYxMiAzLjAxMjUtMS44MDUxIDYuNzI3NS0xLjgwNTFzNi43Mjc1IDAuODA5IDYuNzI3NSAxLjgwNTEiIGZpbGw9IiMxMDBmMGQiLz4KICA8L2c+CiAgPGcgaWQ9Imc1OTYxIiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAtNDk3LjcgOTIzLjgzKSI+CiAgIDxnIGlkPSJnNTk2MyIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTk2NSkiPgogICAgPHBhdGggaWQ9InBhdGg1OTc3IiBkPSJtNjc2OC45IDEzMjYuOGMwLTEwNS4yNi04Ni4xMy0xOTEuMzktMTkxLjM5LTE5MS4zOWgtNzMuNDRjLTEwNS4yNiAwLTE5MS4zOSA4Ni4xMy0xOTEuMzkgMTkxLjM5djQzMzUuMmMwIDEwNS4yNiA4Ni4xMyAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtNDMzNS4yIiBmaWxsPSJ1cmwoI2xpbmVhckdyYWRpZW50NTk2OSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzU5NzkiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IC00OTcuNyA5MjMuODMpIj4KICAgPGcgaWQ9Imc1OTgxIiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg1OTgzKSI+CiAgICA8cGF0aCBpZD0icGF0aDU5OTMiIGQ9Im02MzEyLjYgMTQwMC43djQyNjEuMmMwIDEwNS4yNiA4Ni4xMyAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtNDI2MS4yYy03MC4wOCAyOS43Ni0xNDcuMTggNDYuMjItMjI4LjExIDQ2LjIycy0xNTguMDMtMTYuNDYtMjI4LjExLTQ2LjIybTEwNS4yMyA0MzQ0Yy0zMC40NyAwLTU1LjE2LTI0LjctNTUuMTYtNTUuMTZ2LTQxMDIuN2MwLTMwLjQ3IDI0LjY5LTU1LjE2IDU1LjE2LTU1LjE2IDMwLjQ2IDAgNTUuMTYgMjQuNjkgNTUuMTYgNTUuMTZ2NDEwMi43YzAgMzAuNDYtMjQuNyA1NS4xNi01NS4xNiA1NS4xNiIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudDU5ODcpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc1OTk1IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAtNDk3LjcgOTIzLjgzKSI+CiAgIDxnIGlkPSJnNTk5NyIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNTk5OSkiPgogICAgPHBhdGggaWQ9InBhdGg2MDA5IiBkPSJtNzEyNC45IDg2Mi43NWMwLTMyMi42NC0yNjEuNTUtNTg0LjE4LTU4NC4xOC01ODQuMThzLTU4NC4xOCAyNjEuNTQtNTg0LjE4IDU4NC4xOGMwIDMyMi42MyAyNjEuNTUgNTg0LjE4IDU4NC4xOCA1ODQuMThzNTg0LjE4LTI2MS41NSA1ODQuMTgtNTg0LjE4IiBmaWxsPSJ1cmwoI3JhZGlhbEdyYWRpZW50NjAzNSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzYwMTEiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IC00OTcuNyA5MjMuODMpIj4KICAgPGcgaWQ9Imc2MDEzIiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg2MDE1KSI+CiAgICA8cGF0aCBpZD0icGF0aDYwMjUiIGQ9Im02ODgzLjIgMTE3MS44djIuNDdjMC4wMS0wLjQxIDAuMDEtMC44MiAwLjAxLTEuMjNzMC0wLjgzLTAuMDEtMS4yNCIgZmlsbD0idXJsKCNyYWRpYWxHcmFkaWVudDYwMzUpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc2MDI3IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAtNDk3LjcgOTIzLjgzKSI+CiAgIDxnIGlkPSJnNjAyOSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNjAzMSkiPgogICAgPHBhdGggaWQ9InBhdGg2MDQxIiBkPSJtNjc2Ni44IDEwMDkuOWM3MS4zOCAzOS43OSAxMTYuNDIgOTguMTIgMTE2LjQyIDE2My4xMnYtMS4yNGMtMC41Ni02NC41LTQ1LjQ5LTEyMi4zNC0xMTYuNDItMTYxLjg4bTExNi40MiAxNjMuMTJjMCAzOC44OC0xNi4xMiA3NS4zOC00NC4zNSAxMDYuOTQgMjcuOTQtMzEuMjMgNDQuMDItNjcuMjkgNDQuMzUtMTA1Ljcxdi0xLjIzIiBmaWxsPSJ1cmwoI3JhZGlhbEdyYWRpZW50NjAzNSkiLz4KICAgPC9nPgogIDwvZz4KICA8ZyBpZD0iZzYwNDMiIHRyYW5zZm9ybT0ibWF0cml4KC4xMjUgMCAwIC0uMTI1IC00OTcuNyA5MjMuODMpIj4KICAgPGcgaWQ9Imc2MDQ1IiBjbGlwLXBhdGg9InVybCgjY2xpcFBhdGg2MDQ3KSI+CiAgICA8ZyBpZD0iZzYwNTEiIHRyYW5zZm9ybT0ibWF0cml4KDcwMS4yLDAsMCw0NTMuMiw2MTkxLjksOTQ1LjkpIj4KICAgICA8aW1hZ2UgaWQ9ImltYWdlNjA1MyIgeGxpbms6aHJlZj0iZGF0YTppbWFnZS9wbmc7YmFzZTY0LGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFKSUFBQUJlQ0FZQUFBRElVVTFCQUFBQUJITkNTVlFJQ0FnSWZBaGtpQUFBRys5SlJFRlVlSnpsWGR1VzlDQ0xCYis4LytQT21qVi9SK1pDd1EyaVNhcXFEN09HaSs2cXhMTUlHMFNMLytlLy8wdm90K2tYVzhEeW1jcUYrU1BsdkVTL1dMWFNVYyt2YjYvazVUR3UzejlHdjcrSzdwRVFFWlhkeTAzZUgramtJYlYrYXdWTXp5Y0xtZWV0TVJEUGhwK1NQcythQUczZ04rcy9lNW12dE9POW1pL3BxUFg4YUlGUittUWQ0QXZleFR6dmp2MnkzRzhhV3ZrbUdTb1BpcFdWNUlycFBqZ0VoM3lRa1ZqMm5LOU10bTEvWUxLN2ZYM0tjSDlGcFQxaEVFYzdab0VwdldLV2wrc1A5QlpHV2trZkZpTFpTSjJydHIvU054MndUMHF3N3lTYndCYzZLMFNPV2RMM0MrSVNtQ2NrZmxWS3ZZNlJSSEsxeFl1T0FIQk84OEhETzMzaEJZSjNlUjkyN2JGVWU0WGpWWkpJK0kvbFBwak5xelpFa0s0TGZGbkZpNWJSY1Q2UVNGa2RFZThJYlNaazFjYUswbXpma1IwejNxVVZQdnFVTU52aXBBZElZdGNld1VGZXFEbGpsbEJuTnNRU0daeWVTYWZiR0NsYVg5d0hTMElhWkFwN0xsTWJ4N3NOT09kWHpQOGJuZjh6MnUraWMxRURPUkNOblRoZndFSWxqSU5aaEhOQmQ4YnJOaU1KZGVhcEF0L3pDdkU1MXl0VzJEZHpxKzgvNXJsNEYzRit5S2w1WlcxdCs3dnVnMm5STXRxWllkaXBGNFZUeHNyb3FBdU1GSnNWTFRKT0doT3RzcEpJb3FpNkpvbTJlRGZ5ajZjMWtkRy80U3Q2bGFJM1BMYjlqcmRja2s4dDd6d09mQkxGZFQwTkYwcXFjNVprcTlGZFNpU1RMdm82S2RCMVhIakNIaWxXY0RxNHZkKzVCYWJCemRKY3ZIZTBZUDUzS2VYZkt3a1RGdG9lRTIwa2pscXN3RHdwRGtyVkZodjJFT1laVDVsVTJBUDd5U0dwYlpsQWJjVTBIaC9wdDBvZDF4aGo2RWpONjhaTi9qbStSV3RzeGx2M2RIamE1enZpL0pPMEtQeHVuWjU1d29MQ2NlaWlYeVJMU2ROcUVVRTFLakE5eWxCUnVwR05uY1Rublk1NmVrWkNkWlJhWHpVeHZjV3ZCT3pqRWlNbERPRmtXbjlmYnVDWFYzMUhuOTVubmJxMHdEU1RDbCswQXg5WG5FTFdwU3RUUFN2SmhmaUlpSWpRV0o4WVRiekxZTkZPbkZxbjJncmdJQloyMG1NOGIzZzcrbjNpWkpwVWd0cm5QR3UxcGZrcmVWeVZNVTBtNWU3UVV5L0ZWYjZuZEZXT216aFVXd3NHYlV5VVNCTWk0L0xJN0JNRzB1K0FqMkk3eFZUaGVIWkl0OEpLZHpBNnZGS1pXSitEdVcvTkJZYUtrNWxhZFRMS3NUU0wwWng4UGVMcnp5akhXSXZFRy9vdWRYZlhnZW5xN3gzd1NtNHVTSmdtTGhFU2w5R3BwVlg3T0taRGJjTW1iYVFYWFR2REhGS2JqSk9LTERENllHSk5nWEd3M2dwdWJpYytKQ1NudW1vZUdhQXA0cUxMWENpVDFaalU2YVRjTHdUdTdNem5xZThKT0Y5Wi9Da3pFVTBBUDlZL1NSZElYNTJLeElhQkJES3MxQmQyVjVrSDFXck13WUtxU0NYUktGbWxFOUhBU1ZVQVYwbGNPWWs2czFiVEk5TGtidmp1MnFiMkdoYkpOL0xVYlEvRUJEWDNrMzZ2OG5YZWRMakVZNTJwRGR5c092K1VpYmlTOExEcUQvazZ5WHBrZXJUSmprbGlTR3NOUGkvdzJhUlZuM1hjZDV1ODIwRjZxV1M3VWsrcGFndFc1T2p1Zldud0U3VGNPb2xXVXBaM2tiWEd4RkVpT1NieGFmUWRDQnBYdnlCb3huYnBYQUhvUDZoVzQwd3ZRVGhsQXBhVzNsbDNoSitCSTFCVVVzSUFXTFpNNDduTXV3VFhVY3I5dkNiYlU5WS9XSWtwQSsySzR6QXVnU09tdkIwRHRVVXNMbDBzQjMxS2pobk5SUUJsRW9TUnpHYTZkSFhXS2lyaHRYRTMyUHZPdXV1ZmtVSENnaGlkVU41RFgxVm9UYllrWndtMGY2OFVMZUZQMCtXdUVOQXUzSWFJOXRKS0pVdm9UeXhTT0hmNnVzK3F3dnJFYVJkVTRqVWdMZ01iWWQ0aWROQlo3WVdDWlZWSmhwMXFhMXhCWUNGUUExWVdTY3NNSFkzcXFrUTlHZ1puS1lVeXVzSkszOHhJVEd0VjVCSmRVTnJNSUpvbjFSYlNERFdWbE9td1JmOENjMXdWNmpqODRzZFBDaEdkUk1mNTlSOWlZZys0YVVpVVFtUFY2RXBEeTQySnFZbzRYMDhKSmlTTERLc3Y5R0cyRmNtWUw5dGgySXA2cTIrZitEZndrWkhoaStkWm5hU1pyTE9aQmtvUnNNcDdXYmI0aFppWkJEalMvRWVnUXJSdWlXbjZpNE9xT0JaenpDUnQyOE84eStBbWQzNHVUYS9KSkZFZnFkazFXallpSE1IUHRNU215YkJodnY3L1V5b3NjMU84U3BQYVM0RGhjbDhOazU2TkNXdzhkNzZQd0hRMU1JcjZqeWJTZEdjcnpCOWt3UFlTSGZTZkwrcTgwQ1FUQjZ6U21ZbG9BR21VV3ZwZDIxMUFoK3FFRzVOaEFGdnNySDRWNy93TWJmYlBrOW5OSWkxZmNVcE84eDNLZkVLU2pIKzZxWHFEZjlwM1hYWHdNTE42UzJjV0ZRQTh0QUp1dVhnd1BiNHpDMGd1bUpNQ1ROakxPWVNxaSt0cERLQVZpbmZpaWFTbXVCQ3MvbzA2c1hJbG1XMmp3TWtiR295U1NLTmJKVnlYL1FuQ01WczZHSjl3T3lkc0RhYThUZm9HRERxNEc2RUlQQnRWS1pabUt4ZW4vS0N2MDZTQVNwSEJJQzNUYkdhemhTdzQwLzlrYjRWUklnME1mRWVwQXl0RXZBODY0cTJVd21PY3NML2dCVmhPNmFaeG1Zb1Rvc1NTdzRYVTM5M3hKeEdSL1BQK1FzV3lMZ2lPaUloYmtOdkFTRHAvVGIwZUVqemJMck1NaVlQK0phTGF6WHJ1R01xM1VIR1Q2L09UQlFmNXAzYzNuanl0YjZyN0pyMFUvTC9RMDVuek5Hc0s4ODE2QXliMVd4N3c3QXhTa210TGNIcHBKMW5iOVIwVEhYU2VReEtaUEZOcHd5VEpuaGdENkM3UU1zRU8wUEMxWlhnbzRoK3RkZzcrajZ2UDA1VksvYzdqU1kvNGFKVllEWlR3UG0vMndEaWlQcDlkbmdTYjJmUVVmQ2Z1WFhRVktDNHlYQldsRm5OakpFM2NKRk5qcGlKUVZKUXVSQ0dBRGRNTUhHVE1ncXJPT1IxSFQ3TURBamNPU21SSWRDcjdEcjE2OHZidXlkcXhZQktnVERUdC9VMFRyL2tEYzB6ZktSODBDWlBoOEpQaUt5SHpDMDF0RkdpVHFveVREZHNkOG5YYUJxeUlVQUdScUFCWnVwOEl3YldaL0xIek10N2hDUmNuZVNwWWRpVHduQnhOSVNlVTBEM0lkRW5mS0xqdTBjclZFYjY3clEwWGZPWlRTcEhKU3JiaEJYTS9DdlMyY1IvR293eGp3SVdQbUdjZDk5cWdzdElMWTJGd25jTC9hQzlpaDN0RlRucUZkMHZxRVFiNDNiMG12M0t6c3RUZ2UySzF2UXZHbnpDaHJkUFF3QmllczdJcDFNZ1J4cmxaVk1RZWxoUUtVNWZncHRxM0hjelk2bVV4RWRWL0FXWkFjTzBoNTlsVUdaVG45c2VxMzJjekZkZ1pUU2ZYVFViWUZuSFNaQlZEVkRrOTQrWlVqbmdGSkVSRGdvYnlJZ2JiMFU5SW82djIxQlhuSytPRW5DYjlOVjQ3NWt1MlJLS1VjZGhYeURrNE1US0FxR000eGNZbHFGNG1PdVNzZnA4ck9aUW9aM3pHdlg5Q0JaZE9EYlZyYWhubEkxT2g1eG1qUklkVW1RVTdTaXpQdktHRkQ4VE1Kd0Q1bFNYbDRBYlcxd2VobkdIUzQxRWxFdWRFWkdXRUxoVUtnY2VheUlmeXdMU0kxbisydW0zaGNkOG00NkEraWZ4ZVd5OWIxU3B6azJLSGhzV3BwRUhzMC80elNKaFdNZGY1NkpIbWNLWjdNa0Z1MjBMQ1IyQ0drbVFlanRLTVF2cWJnWFB2cXJXWENDczk1OGNPT3dLWEZ5S3FhaUZyUGhoUUJvYXBDbElGR0Nnd1ZCc2pDVTFpb3VKQ0FMdjZjZkpyRXJFSGY1MURTb0RlTmFrSmpEV3dEaHZvY2t5bnpzeGt3dEZsZ0cyWkxEUGN6NE5WaWY2b3Jab1E5eStsZDczZUdjVzIzT0xqUkpYSGQyaVpUYzdoc2c2UFphSzJIMWZHZDErK1RGSlBla0x1T01uN2wwSWRaWGk0bVlnT0VvRDJIWU1VY0lFekVaV0svQW9jRE1jTW1NUWtDbWUyUEpINXBFSi8zUC9RN3IwWnY0dGZXdEN0azdpWGV1cTZqTlZ0S1ZOVitHVnh0cjh4UVJLeFdqMkFqallRb3lsUDVEYlZpWWk0MUlHOXVNM2JHSjR4OGhncE9hUloyOTNRS1RqcWVUckpFNDhrTVluYnNqREpnOXNZbWhjK3UxQVRORU54MVFSSmljd1l0YU9OTWVoK3BORm0ydFAzM25RNDZJYllpL3lxOGM5aWc5a29iWElaUVdhV0h4a0p4dCs1Q2RRdzZ1QVpWVjRyeEVzcUlTS3VNbFNxT3F0NUFPM0tSQWQvaldhYWl0TEo2czRrdzBNT1dMY1VhTzFOb2JVZ21HSkl4eVFaTmpGSU5nSmFENWF6U2ovbGQvK1c5TEV0RWp4M3Vram5BSGg0N2lJd2trYlpjVVRub1I0Z1BRUDMyWTBrQ2xrcWRaQ04xaHlQK1B3bWZRQ0VGMWRNOTJ6RElKdEtBZEFzVVFyVU9XYmJxa2gyK0l0ais2RTJHNU42M3hHMlFjbXB0K1ErcGl0eVBxM3RqdmhENkwwcHk2M3FWeEI5WkJCYWpOSFo2b3BuMk55WHNJbzFyRFpDQTZZbVhXeUxKS2dEVTQzS2VWWWUwVUUxWGpraExuN2JPZmhRSUlGL0NWVVl0cHViYzhKM0FqNFg3ZnNDSjJrSExhUGtqTE9hcC9KNEJoK0lJMG9BckhzbkxoMlJiaTNjckZrUDkwUkxDOG9iY1VNald4WXZydUM1bGNFZVMwVVZ3T1JDcm9rNm9BOStKNWVQQmZiYWFsLzMwdlNoWXFVbVRieWNpQmJZVUlVSWtvRVNVT3hPajJqajVqRUkwbVRxOHpiOUUxSHdpdEI0U2lXN25BSHFqLzFmSHFkZUVRL3d2UVR4TkRiVDR5YXR0Z09mQ2JWUTNHRWRpb2t1cTZOSWMwaTZVQS9ZN1VkcFFZYVhGTFVEVS9CZ0p0Z2RiUDh5Sm9KeUhjN0QzaVlXM2tSM0JRaWNFdjQwN2RvbzA2cDVvekNDazB2SkJROEQxNFR2MUVBODNqN1NIalo4SzRYTUorWEs3aEt4bFRNbXc2Q01TaWh1K1EvNittcjdheFZTb2Y1VGt4NmFnWnVyNnZ2cFdONnNyd2EySS9DTHdwbG1nRDYrYm1PWjFtNEJtZVQ3VDBpYmpKYVN0aVRqUURNVzR2REJ1bFhuTXIySzB0bG9xcXdTd1JtMWFPMEpsWkpZaHNXcXNqYWpXdE9UUlpyMm9MTzZtQjVCcjNVLzVJUWhJZEZhY3l0Qy9KNGRFUkYzRVlpZWF1bmd1N2hCQTlXcDZaNzRpWDdLclA4RW9VVjM0U1p3MWh6c253MG03VXh6ZHE5M3VPQktnNlVGckNPVkZYWVMraHhaekhvN1VlcHcweEQ5NkxhY2JOc3IwclZSODJ3TEFGT0dGUzNpUW1NVmdCa1lJOUJrbWZTUTlPUFlhRjI4ejlSaHFoVUVtVS9XNlY2a2pIbGZLWCtYeDBtSDZZeDlYc2JxcmtuRlIrc05ZUDladE53RTFQdFR1OUpEU1hxaTBoak54bDh4VXBGeDZTVEQvZ3Qxb08yeGl3cE1oUDY5TGRqYkRPUFU0THdkNnRxVlkzMWRXWEVKdm5KcExtYmI1WHZJR1V2SjZJZGpteWM3VmFMNUdCNDZhUVQ1K0FRR0lQSmVDRzRid0pYSVdWcnVmMGxBdVRWRXhRYTV1RzBTSnVidUNUZk0xUmV2Q0IxOG5qMVVwT09aanBXc1hKQSsyZEZxMi8zSGRIYXBBekFjNGsyaHhLc05uMitxS2N2M2YwbXRaWlJJa0MxL2I5U2hubEZqYXZNazA2cGhGOGVrZ0Z2dk9VSm13MXZkR0R6cFRTczFEM2dWSVc0WXFYTlZyV0ZTaHFQUTFLNjcvd1FxTVFrQkQ5V2RRRDcwSTdOaFhXZ0lISVZ5YVNLR0NpUDl5Q045UCttM2tKTkNUQTZqcU1yeGtrcGNQblhWV0dSQWR0b0VUdm5FdXlpNWhCS0ZxRkNscXBLSTFkSUQwWDlxUFlCMVZlSUowMEhubCtFakpnMERZZEtmaUZDd2JKZEowR0N1VkhKa3M5VExpNkd6SldIR3JKd3JkYmVzTjFDcWxyNFpWRjBXbitSQk5iYkxZNU91ZFFXVjJiemRQQTJhd0UxR3J0Z3l5cXRkaFhGcCs2b1k2RTlFUkVWNkRGV0wyejdvcTE5VG81V0kxNmR1MzQwSXZGaWcveFFQVFlmR1ZmVjRDODA2Qk8vM1F6NlVlVnl0Mi9IZU1PZWQvRThwNjBFOGlEaTlUd3dPVjA0UXYzTWRBSUJwSHB0S1RCeWlLRlZLcVJXR3ZpTjlWN2hTWlNZNXhacFFNY0N0aC9McTF4SFlCcmhGRzhPMXhad1VaV3A5bjFobVRzSWtaVGtLNWZqNGJsUmg0cGpzOGFUZndFNFIwRDZoeDhMc1ppQVU0a1g3WGh6aW5OWGRZb1V4a1oxZHM3UmxhQmppSml4aS9MWlFYd1NtL2JoSm9HNzJxOXRSKzJTYnRrMTlDV2xNa2s2Zm11cFNrM0VRUEJ5cFplQmVHeVJOTmdpeG5MaXBtMjBhM1RreWRQV2pPRVRQOE5TMm5NMjdWUHJnNzZnbGpZaFowcU5PSUhuMHN2VXB2dHFsSC85c25jTkpJWGVrTEtoR3RmeVVIMm9oRjc3V1F6YUp1RHNralhYcWFENExPVXVzV01uRFVqSVZLSzZQRGxQaGMyeXJXMGtUY0diaTVQeC9IUHBvRVk0Q1k3cWZwNWtwTmdtY0RTTnBFdnRlbStvUkw4WWRRemhuY1ozWFpHUWV4VVZNVFZyWnZKVVdybUpUWHp1T2NtMXFrdEpDYlowVTZaUHI5RGVlSmxHR0VRRGhRZDBaTUE4N2lHNUFRY3B0Qjc3bTQzNVhzbndTVDMrS011WjJ3Qm1mRTAzcXdDWWV5cW9sNzJzejhkdWI1djMyYlRCbzJ5V003ZnAvTmNZeHRjaERNMVVtKzIwVEpyM1ZWaHRVQjllN1JzZmQvczVva1FsRVpvWnlJQW82bHZtU3NDeDltR01zamtXNlVuNURBajBoMzI2ZVhrUzF4elJiV2ltdzcrTTFIZjltc2d2WTdYZzI0QXM4ZXFiU3pWZ2dTQ0dWV3ZhL3YyZ25iVW5NQlJCQmN5dk1pMXVwN003MWk4Z1UrelAvNE0xb1dCeU1HUy81cnp0cE5lVmRsYnVqZDBYV3pZclNhaEx0ZkprdmdNeFo1Wk03MTlZc01kMGJZNnkyeFVqRlp6R2M5V1FuTld4SDRoeE1mN0JaYmVMYnAzZ0lnTGhlOGpBT0FQVC9Na3BISFUxRTg1WUw1Y3hoSFVud1ZFYXJ5SURMTS84eHpPVVRKTW1YeEVub2pJOHdKb1piTXBBZXExRklrYVMxTkVHQ0RRbWlsOU93MVNzTTZKdklBdUhFR0lJNzAxRGJkaEcvM2lGQzBsdEs2aUlhbm0yVlZqTHdUeitTTk1kaWV3Q09BNUgxbVBHN3BFbHlwcnNyUlo1Y01mdWRGQVpxMWFvdGMwVHFFODB4VFdRZ2ZRL014VkNYcVVOalpqRzhoai9EUlhwbE1yYXhOSW5XYnJWVkowUzNsSmpJN2ZCUEc3QXFsYlQ4d0JUeHU1TkFHZTRKR0F6elRpNkhKTzJjQ3hQOEVhaWRIZ2FZMjJhdmtxaEY4OTNFN0RHK0d3WThMa2JuYjVMQlVOeWYxNDV0RU1pcmxhaFNiZVJqb3U2d1BQajhHcXZXMkhjd2laTmYxRlVjc1pNNitqbXF0UmhHNjlKcmYxQnlSU21WNUJ0cC80aWsrUVlTQ015MzhXTVBqWVJBZXRYQkVJN2l1Z3JUbktYVE1DSDlDVDIvWjBjV3lOUkQxVHB6eWZCc0Y2Rnh3QkhEYUZYTmtZQTBFUHJuZWo3U0x2RlBHQmpNdDNvZUYrNHoxcm5DU3ZlUzNhWkg1ZTJsNUtTMmVZeHRmRGFWRmxTZHUrRXR3aENlbTgwQzJndWtUOFg2V096aWtOcXZ6eGtYdHRzSmtIR1hwS28xSW5KN2NQL0lOeUNPbmRETWpPNWRrc2MyaTJOQm55SXI2eWNsV2FiSDM2Q0FmWWpGUVVCVGEwaytVelpSclBXdjZQRXhKWVNZQ2wwQXpPTkVUUC9YckxZUXNXZ3FSK0orUGRseEtDWXZnZUwrMjFCZFhwQVdvdG15RXFMSU5SOVJYV2taUDR5WnZENStxNmhtcWZsVmFXUHAxSlk0NW1BUVh3YXNhYVFmYWd4eEViYS9nV3hXUzA3RzdvajZtZHBlVzRVR2RUU21ZRG9DWldzem5uY2p3RWN5bUVCWHgrUzRoSUhCb3UwMmpjQjhqdTdNeFIvQjExdTZXaWVMOXdNenlTUjk0cnFKeDQ2TTJUcGVxWkRPbUE2OTR3R3dONTdpb2Vxb01TMmQvZWIveGhpcXcxcklCd0psazBaZ01mbFlJcmJuVFVxSlNhWDFFV3pQcEFpOHg5cDVRSzh5ejUzVHRTdmdONlY3b1JFdkNkNCtRcHgwT3dKc0dvd2k0ZjN3WFFHZ1Z1OTJmeURzTXhXV0h1TUcxMmdYYmplMm9lOUhxSUd0ZjcxUXhEbW94akI5WXl4L1p4SVlGT2JFSEpLSVk1K0h0M1FhdEF0NnFnSmo4aytHNldaZzhYYmVaMHlZUWI2Qmd3VFM2VTE0a21Db3hqRVNHVFAwUTdkTXVJejhEdEpLeDBqb0pUWUdFRkJKeWkzMjNhc2V4c01Eb2NQdVp5UUV3Mjh4RGIxR0MyQzVwWjkwVUY1MTdKWDJKOG1td3dUc0djb01wWERMQ0pubWFRbjA0djZHbXdCVHdlMXd5R2hZMTFGcVdKTDlYSnRKbURyaWs0YWFFc2QwMDBrVHJVVDg5K3l5aU9XNDdlWmdOd0hadTNDM3dZL1JYYUFkREpXbitBbGRBMnEzNENSYk92WWczRlVQUUZpNFFSYzdNMXVwWTVrZXZxSUpldVpDUk1kNXR0MS81aGJFTml5MkFhQWoyRWI4aE9NVDc0bHl2cU1NRTFFWXR4MHpST1IzUlR2d2NFVlhTUi94WWtqOFJDREcxUmpmT1V3NVBXN2ZPVEJadU1yRzh1RHdtc1FaNGJoMjFTRFRPTnZJL1hnYkVSMUUxVFpqZFplZjBleEhCb0NyM21JUG9uckxMblBJVHBzNEpuSkxKWXo0SlpCZE1GclcyQ3Y2THFFVloveE9aUlBqQlRFdkNmTlF4MGJVaHg3Qk5Gdys0NlFXU2pIQXF4VWpBNWljQzBLSXFaU21CZy9XU3lRQ1RzcWtEZ0pzZDgwTmlKWEdsSDRNOENnM3ZuQUtKd1c5TjZTUVZiSlk2aitveVI3UnNEejg5NHcyVEljWGtOcDBRREtCZERaTlBJWkw5OTZVNmVMNngrY0M5Wm41TCszWndWWEdSR01vU0dBZUxWbHZ0dlhTaUJ4Z3o3cGRDZFFrcUU0b3hZK1EySjlBU2JwbDJwdjA3bSszdjJMMlQyMUlDM2Ivc25UVFQybnhZQ3JuclE3ejVSYXpqRE1XK3V1ZkZWbXo3K282ZGRqbnI1MUdJVHE0bnNGcWEyQ3FReXZTUzlrUkswbjNQWlV1RjdYS0REdzdxU2JKbUt3MmdseHVwQWVUdHVTUDhHSjNnLzVMOVd6S1c3MjYxWVJOSXBicGJVSEpreFNGK0xRd1VhVnhQS2tRKzUrU1lMakZSRG14WSs0ZWp6UnVCaHlYYTZIVUdaOVZPbWxRbldFaUlaZUd5RE1WTW93YmN5ZnVhS1FXVEJBeVBmRWIvWlJheTJicFhjcmlrTzFkOGhCaXJlMnRrSGNjaGx5cXRrd2E5ZmNWSWg4TlovV3lpRlFkdHNuV2Q0ZlVrOWdtVDhDVTVDbmtGa055dFZWNHNOTDZHS2gxSkhrVGU2WWxUTDFOQ2szcFRSWDE0L1JBY3MyaVBNOFRNUk9vZlpUOVE5S0E1N3NuYU1IOVRRSkpUOU9BOXhBZUEzMjNad2RKUDJtcjZzeE9ka0JBcG1PbzBRRlRhYTVQOFNwbGZRKytwdFMzdjZBNzZiS2Y1dndwYWZTVTBwWDJzTEU3VEdqaUIrN2wxclVQZGJkM1E2cEVqZEx3a3RpUEEyZ1lpZUVrcGk2VjJzTVdqeVRRUCtuQVdGUTllWGtaR1FmSHhUemU1aWFZZjFLVWlTdzRLaCtncE9EUUJvcXZzakZkenMxUFNhMUZBMWJWcCtvclNYQ1pqZ2lOSmlJQ0xhUFNCTEVPQUdmTnJoSklCdlBwWldzVnM2bzg0SDd6ZnhaM3JTVWF4TUkwN3Yzb2o1REFUditzbFJudFMvTXZMTkFnRnJ6a2lnZVNiU3I0bCtpeStnWEQ3SjdoR09HNHdZcFhoQ28wYm9lSnY4dlc4TkJndVVuT3M5aWhBeFdLaGRxdmxyYmdmNG8vZ2p3bUZ5VVZUcHd4Q2tvbmNWbk40ak53UHNVOUpBT0RIWHVpQXYrZlVQckwyUXNHYTJQZXZ6TEJhUkV5LzVHanJoSjEza1huTEtZRHRTbjlxcHZEZVFJdHNtbHNxa3AvM2xUZFlIZWhrYVpkdWo0YU9DdzIrQm1uRlVNSTVXcU9Gb1AyVWZxMG12dE5ycC9yMXBBUWZSMVYyRkJQQzNXZ21LaURJenNDcFZLcEY5aHUvbmVNMVA4cGh1b0swaTRnc0swVGp2VTU2YVhIQXh3K2tpN0ZWbWJka2lMS0N1L3VZSWEvU010MnI0RGlReVlWLzhFTUk5TGxMUU96NjNTQ21sUUZaRnBIaHRxTHpha1VmaDFKQ0xZeTNKMUZuWDFrU0JvRDNqU0Q2aExBM2loQi9QMHFsN1N4L1ZVTXg0Y2JPUFVydEozL01ERVRLRmtCeDV0MWNqSkVSQU5rYTFLUlpLN0lmbnRrWEhmZFh4Umd3cDdoRUJuUmtQNFVTQy9FWkJuRWF0dnp0TzA5blRKZmpyMnVDWmdvYzFyR3Zia3JSdm5MV0V2Z2Z5YVpYZzJEUVJjQU5UeURoMXlWa0grbFN4NlVWaXEvREdURDhTZlZWTWZxWGtpdExkMnBsMVp3bEVReC8yUndwVEU1Ri9Zd0tuVWlqeUF4Kzl1TThxckkraENIWnN5RWQwOWZObS9SRGx6aGdiR2lWMlc2dkNzcFZsV2p3Wm1lK0lpZEdCSUgrZ1dOOFphbUJDblZrN0V2YjkvWEI4d1ZtZWhoOXJucU56QklSbmREUGErU0Rjd3cranZsdWQ5ZUxVYWxpTTVMWEtOVC9WcDFINllHdG1ITVdDVVkwWUdYUlJBTjlWWFFFcU9tb3BvazZyZmRNbUttWENwN2hIWFYwOWlUSk9PdHpkVW5Mb01QNjd0SHhWMGtkZ082eWJLQmtUR2JTUkVhYW14WkZ1TlhOc2lDeDhiUmlqdHMwcFVwZ0J0OVB3YmdIcXpvWGprR1lyckJRTmo0YkRBa3BQRk5XZEJmQmtNM0thcTVYWmZ1cEZrVXJ4ZG0rYndEeDZMalVkL1l2aHd3RWF0RTB1QWtBbWJTdWVVd21ZTkpCaVRuTGlrcVZCYWFkZDJySjJuZVVXZC9rYTRZNVozOEZFMy9XVUxablVuaTMyUFpxdDVRS1poMUp4MGpZYlNqVU50bncvZ2prMVJMbGNYZU91UDdjT0VqOUhCRi9qbjY0YmFYQmRTTVRPUmdDd2dSL0trYVRYaVk4N0ZuaUw4VWlYbzFvM0VmdU9KNWRnbzRVL1VmcDlWRS9DVUcrNWgxK1p5ODVocjZpdTB2dVRrMmlhVVNLa3lnT3FpTjhiejVMMDRsOVV1N1VpWncrS2VYWmtGUHBGR1ZFRTd5VFlPWC9XaXdvNy9DUkVTMzJpTGZlVlVQNEoyVmgzcEpnYUdtUTVha0VvbUkvaEYzak5Oa1NIcW45YTcrTEFMeWJrTmZwTlhBdDkrdi81dWdhVVFaL256N1RFRERFWHNFMDVrUjV5WVVNQlM2bVUyVHhmUDV1azhXTGE4WS94dmZyejcvTlAxVkppSnFiZnVWOWlVZ09YNmVzaXc4S1F5Q1J2UC9MME11U2RHeGtqSzVBQUFBQUVsRlRrU3VRbUNDIiB0cmFuc2Zvcm09Im1hdHJpeCgxLDAsMCwtMSwwLDEpIiBoZWlnaHQ9IjEiIHdpZHRoPSIxIiBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJub25lIi8+CiAgICA8L2c+CiAgIDwvZz4KICA8L2c+CiAgPHBhdGggaWQ9InBhdGg2MDU1IiBkPSJtMzI0LjQ4IDE3Ni42N2gtOS4xOGMtMjEuNzM4IDAtMzkuNDIyIDE3LjY4NS0zOS40MjIgMzkuNDIydjUyMy4wN2MtMjcuMzQxIDE1LjY0NC00NC41MDkgNDQuNzc1LTQ0LjUwOSA3Ni44MjYgMCA0OC44MTEgMzkuNzEgODguNTIyIDg4LjUyMSA4OC41MjJzODguNTIyLTM5LjcxMiA4OC41MjItODguNTIyYzAtMzIuMDUyLTE3LjE2OS02MS4xODItNDQuNTA5LTc2LjgyNnYtNTIzLjA3YzAtMjEuNzM4LTE3LjY4Ni0zOS40MjItMzkuNDI0LTM5LjQyMm0wIDE1LjVjMTMuMTU4IDAgMjMuOTI0IDEwLjc2NSAyMy45MjQgMjMuOTIydjUzMi42NWMyNi4xNiAxMS4xMDYgNDQuNTA5IDM3LjAzMSA0NC41MDkgNjcuMjQyIDAgNDAuMzMtMzIuNjk0IDczLjAyMi03My4wMjIgNzMuMDIyLTQwLjMyOSAwLTczLjAyMi0zMi42OTItNzMuMDIyLTczLjAyMiAwLTMwLjIxMSAxOC4zNDktNTYuMTM2IDQ0LjUwOS02Ny4yNDJ2LTUzMi42NWMwLTEzLjE1OCAxMC43NjYtMjMuOTIyIDIzLjkyNC0yMy45MjJoOS4xOCIgZmlsbD0iIzEwMGYwZCIvPgogIDxnIGlkPSJnNjA1NyIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgLTQ5Ny43IDkyMy44MykiPgogICA8ZyBpZD0iZzYwNTkiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDYwNjEpIj4KICAgIDxwYXRoIGlkPSJwYXRoNjA3NSIgZD0ibTY1NzcuNSA1OTQxLjloLTczLjQ0Yy0xNTQuMzcgMC0yNzkuOTYtMTI1LjU5LTI3OS45Ni0yNzkuOTZ2LTQyMDUuNWMtMjE4LjEtMTE2LjItMzU2LjEtMzQzLjItMzU2LjEtNTkzLjY1IDAtMzcwLjk2IDMwMS44LTY3Mi43NSA2NzIuOC02NzIuNzUgMzcwLjk2IDAgNjcyLjc1IDMwMS43OSA2NzIuNzUgNjcyLjc1IDAgMjUwLjQ4LTEzNy45NiA0NzcuNDgtMzU2LjA3IDU5My43djQyMDUuNWMwIDE1NC4zNy0xMjUuNTkgMjc5Ljk2LTI3OS45NiAyNzkuOTZtMC04OC41OGMxMDUuMjYgMCAxOTEuMzktODYuMTIgMTkxLjM5LTE5MS4zOHYtNDI2MS4yYzIwOS4yOC04OC44NSAzNTYuMDctMjk2LjI1IDM1Ni4wNy01MzcuOTQgMC0zMjIuNjMtMjYxLjU1LTU4NC4xOC01ODQuMTgtNTg0LjE4cy01ODQuMTggMjYxLjU1LTU4NC4xOCA1ODQuMThjMCAyNDEuNjkgMTQ2Ljc5IDQ0OS4wOSAzNTYuMDcgNTM3Ljk0djQyNjEuMmMwIDEwNS4yNiA4Ni4xMyAxOTEuMzggMTkxLjM5IDE5MS4zOGg3My40NCIgZmlsbD0idXJsKCNsaW5lYXJHcmFkaWVudDYwNjUpIi8+CiAgIDwvZz4KICA8L2c+CiAgPGcgaWQ9Imc2MDc3IiB0cmFuc2Zvcm09Im1hdHJpeCguMTI1IDAgMCAtLjEyNSAtNDk3LjcgOTIzLjgzKSI+CiAgIDxnIGlkPSJnNjA3OSIgY2xpcC1wYXRoPSJ1cmwoI2NsaXBQYXRoNjA4MSkiPgogICAgPHBhdGggaWQ9InBhdGg2MDkxIiBkPSJtNjQxNy45IDE1MzEuN2MtMzAuNDcgMC01NS4xNiAyNC42OS01NS4xNiA1NS4xNnY0MTAyLjdjMCAzMC40NiAyNC42OSA1NS4xNiA1NS4xNiA1NS4xNiAzMC40NiAwIDU1LjE2LTI0LjcgNTUuMTYtNTUuMTZ2LTQxMDIuN2MwLTMwLjQ3LTI0LjctNTUuMTYtNTUuMTYtNTUuMTYiIGZpbGw9InVybCgjbGluZWFyR3JhZGllbnQ2MDg1KSIvPgogICA8L2c+CiAgPC9nPgogIDxnIGlkPSJnNjEyOSIgdHJhbnNmb3JtPSJtYXRyaXgoLjEyNSAwIDAgLS4xMjUgLTQ5Ny43IDkyMy44MykiPgogICA8ZyBpZD0iZzYxMzEiIGNsaXAtcGF0aD0idXJsKCNjbGlwUGF0aDYxMzMpIj4KICAgIDxnIGlkPSJnNjEzNyIgdHJhbnNmb3JtPSJtYXRyaXgoNjYxLjIsMCwwLDE4NC4yLDYyMTAuOSw5OC45KSI+CiAgICAgPGltYWdlIGlkPSJpbWFnZTYxMzkiIHhsaW5rOmhyZWY9ImRhdGE6aW1hZ2UvcG5nO2Jhc2U2NCxpVkJPUncwS0dnb0FBQUFOU1VoRVVnQUFBUk1BQUFCTUNBWUFBQUJPQmxNdUFBQUFCSE5DU1ZRSUNBZ0lmQWhraUFBQUdVNUpSRUZVZUp6dG5XMnNiVWRaeC84enN6L1hVb3VsTFczdEd5M1NsRGJGcHFEV1lCTWhRaXloRVJ0ejFTc0ZtbFpxYlNtcENKSUdRU1RWbXZwU0lwQWlvbHh2M3d1Tk1SbzFRYU9SU0ZDRGtVUy8rc2tYMU85N3hnOHp6OHd6czU2Wk5XdWRmZTQrcDNmK3ljM1plODNyV2ozejYvOTUxcXgxbExYV1lXaVJyRHNhbDh4WnU2ait2VGRjMUZWUDlZemRPZVp2ZitQZk8ydk9qTmQ1cmozemNvMy9ma3V2YVZlZmxiTGFXRko5NlhkT3FpY2ZzeDExWFB0N01WZUhzajZnQmt5VzY3akNaTGEvZzdZL3hPc3lZSEwwWWFJbnZRNE5EUTJ0a0Q3djNIUDJQWWVob2FGanJ1KzU5aHBzQU9DOGM4L0JmLy9QLysxN1BrTkRROGRNcjczbW12aDVReC9Jb1F5b0RBME56WWxEaERUSm1aeDM3amtZb2MvUTBGQk4xMTd6R3ZINFJqeUs0VlNHaG9aeTFTQkNxc0tFTktBeU5IUjI2NXFycitxcU53c1Qwb0RLME5EWnBWNklrTHBoUWhwUUdScDZlZXMxVjEyNXF0MWltSkFHVklhR1hsNWFDeEhTYXBpUUJsU0dobzZ2cnI3eWlwMzFkV0NZa0FaVWhvYU9qM1lKRWRMT1lFTGllMVFHV0lhR2pwYXV1dUx5USt0NzV6RGhHbTVsYU9obzZEQWhRanBVbUpBR1ZJYUc5cU1yTDcvc2pJMTFSbUJDR2xBWkdqcDhuVW1BY0oxUm1KQkdYbVZvYVBlNjRyc3YzZXY0ZTRFSjEzQXJRMFBydFcrQWNPbFRwMC92ZXc0QTB0UEs0NG5sb2FGNVhYN1pKVWNLSktkT240WjY2U3RmZHM0NU9PZndVejk1WXQ5em11Z29PcGJ4RHRoSysvRU8yRVZsUzk4QmUvbGxsNnlhMjJIcWkzL3doMURLdjRKY2ZlWExMMGFZMEwrZk9mblRlNTdpVkVjSktnTW1sZllESm92S2VtQnkyYVd2WGpXZnc5YnZmZUgzRTBTVTh2OWVmT0Y1QnlDQ3hES292UGV1ZCs5MXdqWHRHeXdESnBYMkF5YUx5bXBqWFhySnhhdm1jQ2IwdVNjL0Q4QURSQk5FNk44THp6L25DQ0lBTW9maXJJVjFEdmZjL2I1OXpyK3BmWUJsd0tUU2ZzQmtVUmtmNnlnREJBQis5ek9mQlFBb3JUT0FaRUI1L3JsbkhYY2pBREtRT0dmaHJJZk5mVDk3N3o3UFoxWm5DaXdESnBYMkF5YUx5aTY1K01KVlk1MUovYzRUbndZQUtLMmdsUGJ3RUlBQ0FPclpaNTl4cFJzQk1BR0pjdzdXV2xobjhlRDk5Ky90NUhwMW1HQVpNS20wSHpDWkxUc09BSG5zOGNlaGxZYlcvaFhSMFlGVWdFSjExRFBQUE8zS3NBWkFGU1RSd1ZpSGh6N3c0TjVPZUtsMkNaY0JrMHI3QVJOUnI3N29WYXY2UE5QNnRWOS9ETkFxZ3dSQnBRVVVJTURrNmFlZmNtVllBMkFXSk50dzdheTFnSFA0ME1NZjNNc0ZXS09EZ21YQXBOSit3QVRBOFlFSDZaT2ZlaFJRQ2xwckdJWDFRSG5xcWROdXFTTXBRZUxnWUxlK3ppOTkrRVA3dUI0SDBsSzRESmhVMnAvRk1MbjR3Z3RXdGR1bmZ2a1RuL1NBTUJvSzZzQkF5V0RTQXhJQTJEb1pKTTc1UDJmc3JNVWpILzNJL3E3U0FUVUhsd0dUU3Z1ekNDYkhFUjRBOE1qSFB1NFhQNUNGS0hOQTBjcm5UNXBBT2YxSHAxd0xKQUFtcnFRRkVnQXNaUEw5Zk9Kamo1enhpN1pMbFhBWk1LbTBmeG5ENUxqQ2cvVGhqejVTZ0NBQmhXQUNZQllvSlV3QUpLQ2NPdlVsdHlSUHdrRUNRSFFsdkEvSFBuL3FWejYraDh1NGUvM250LzkzMzFNQU1HQWkxdXVwTXdPVDR3NE8wc08vK0pHNCtHTzRNZ09VQ1V5QVdhQk1ZTklFQ2REbFNqaElBRXhnWW0xcTk5aWp2M3JHTCs1aGFoK0FHVEFSNnZYVUtlWjUwUVd2WERHam82c0hQL2dMRVF4YXF3d29Fa3lBR2FCVVlBSWdBMHJmS3dncXJxVFpwQUlTMGdNUFBReHJMUjUvN05HbDErcEk2dnhYZk1mazJGRnhNRU5lRjM3WCtmdWV3cUhwdmdjZWdnNEFxSW5Xb3RZYUZvQzJGbUJBYVNrYUJGakFJZ01LYVZPNmtsNUpybVJKT3h0Y3pQdC8vZ1ArdUxWNDRqZC9vM3Y4NHlBSk1NQ0F6R0hyNVF5TlV2ZiszQU5WZ0RnNCtHVnB4Y1ZmazRPREl2T2dOWXgxZ0o1dk8zRW1QUEc2VkR4TVd0UXV1Smg3M244L3RxSHRaNTc0clZWek9BNnFRUVlZb09uVnE4NGlZSlI2MzczM3dXZ05GSEN3emdIa1BMWTJKbFY3RkozSHduYXB2YTJIT1c2aFU2bE5ibTNidSs3Mnp3RTU2L0RrWnorOXVxL2pwaFpvU0djRGNDNDQvN3g5VCtGSTZkM3Z2UWRLMThNWTUxeTM4NWkwQmJwQ25XcDc2NkMwMnY5ckczdDA4cTY3NFp6REY1Nzh6TDZuY2lUVUE1eFMvL0ZmM3o2RW1jeHJRR0c5VHB4OEQ4eUM4R1Fmc3M3RkIvMk9MRXkwMXBOdzZjVEo5OFF3NnRRWFA3K25tUjFQdmZJN1g3SHZLUXgxNk00VEorTmRsK01nelVCWGhRblJjRTI0UWlCWTA5Wm9qVzFIMnp0UG5JVGRidUdjdzlaYVBQZlVseGFQTlRSMEZQUmpkNTRJdDJYTmFoZkNYd1d3dUMxd0lQZERtOWN5bUVRaVdpeE93aXF0b2EyRnhUSUEwWmpXV29SWHZuVzMxY2JBYnJjd1d1T2Q3L29Kd0Zsc3JjT0x6eDZObDJRUERkVjAreDAvRHFNVm9LWU9oTzY4ckFseDRqNFMyaSt5UU9VK2syVnROVFpLYVVCYmFOdS9UVnpCRDJxM0FUaXVaK2RKM3M1RHkzYU5TYkFoMXpMbmZINzBuZThDNE84U3ZmVENNMTNuTkRSMG1IcmI3WGRrbThZSUpEM0EwRnJINUN0QnBuZXhTNXZXZWxWdVdwdFRNMmNTUXgyTmNLOVp4NDFyYysxNHpzT2ZnTis0MXBvNHVaTU1HdkQ3WUdyMXVUdlpXc0JvaTYxTjd1aHR0OStSdmZEcFQxNTZvVG4zb2FGZDZhMXZmMGYyVkcxcnJ3ZUZPS1VyYVMzK2NrY3JkeVV0MWJiVHo2bjFmQTRRWUpLNUU2QXIxSkhjQ1NqVUVXQ1RnSUtwTzhFME5DcUJvcEc3azdJKy9RZmdRSWxBMHpvQzVhMXZmNGVIbHJXdzF1TFAvL1NQWnkvaTBGQ1BidnZoSHdrdXdpKzQ4bjBmcE5LVmxDQXBKYm1TT1RpMXR0TFgxTnBLM3hJOW03UFJTdFVYdnczYlp6SHZUcFJTT1ZDUTM1RXBGNzhFRkNsM0VsMk9BSlRTbmN3QlJkUGRJZHBPRFA4TFFDK0gybHFMci83bG56VXYzTkFRNmMyM3ZRV0t2OXF3QWhMKytzTWVrSEJYVWdNSlYrbEtTcENVbW52SVQxTHJJVC9TSmlWT2MzZkNGMzh0M0ZFdVFTR3JYd0VLZHljT2JoWW9CSTBlb0pqTnh0ODJGa0llcXFPTWdYTXVQcE5BUUNIWWJMVEdtMjk3aTNjK3dmMzg5VmYvb3Z1WGEramxxKysvOVljQWhNV25Bd3l5UlovZTl5R0ZOb2JDZ1FaSXpBUk1kWkRVd2hzT0VsTHRBYi95OVFPa250Y1BaUFZEbno3TXFRQkZESGUwcWdJRlFFekc3aElvQUpvaGp3bVFxT1ZRQUlXdFVtbXJQN2tVcmYyRFRpenNnZFl3QUxid3Q4eCs0QWR2OCsyYzlkQ0V3OS85elYrdCtvVWNPajY2NVUyM3hrV21sWjVBQktpN2theXNjQ094YkNaSDBncHRZaDhkanFRWEpEMVBDbk5KYjFyYnFMRElab0dDQkpTV1EvRVZna3VaQVFxZ1lHMGRLRUI2YmtmS29kREZvcjBtWmRpek1RYk9CZUF3bHdJQVZpbW9FTjZRU3pIYTUxWnN1RURPMmdnVlpmMUZ0YzdpbGpmZDZrOFRMdVpmL3Y1cmY3dnkxM1pvMzNyRHpXK01RS0JjSU5BUEVWOTN1UnVodnFTd2hwZVZPUkp5QmkyUVNBN21vQ0FwdzV2eUxmVWJ2amx0VjBDaEJkN0tvWkNia0pLeUZIcFk1OFN3eHhnRHA0TVRxYmdVbWdPNUZFQUR5c2E5S0R6MFVXRkxzSFd1Q2hWUXlNV2dRdjhobk5iNDNsdSt6MThYUzA5aFczemo2MS9iMGEvNzBDNTA0MDAzeDljUEtxMHlHQkJFQ0NDK3pqcUlBSEtTTmV0RDVmdEkxb1kxY2U2TlpHdFBqbVF1dEVuOXlDREpZQkxWQUFvdDNGcFNWcmtBaWZBQ0pGOHBCd29QU2Vna0NDaThiYzJsR09VVHZlUkVQQ09VZjlpSXVaUXVxQkQwa0p4S1RFano4SWY5Y2xHaVZ0bjBvbDNyL0hoMmErSElIY0hnRFRlL01kN1d0czVtZTJQKzZSKyt2bVFkREMzUTlUZmNsQzNDSG9BQWVUN0UxeTlEanhUT2xPVkxJWklkS3lIaUN4ZTVrVGovU2xpVGxhOThvNXJ2by82SHVQemRIS1J3SWN4NEFoU0VSVmE3eTJPTWppOVFLc01lQmNDRmszS0ZTMGtYZ0Y2aWxOb2FZNkRadTAraXMxRnFBaFVMQzZOTkYxUW8vT0ZPSlVJdVFNVmZXd1Vib0FLYXUwcHhjUTlZZ0hCTnJQSmpCTmR5NDAwM1ozQ3hiSXgvL3VZL2RpK2NzMUd2dSs3MUFKQ0RvUUVQT2s0N1FpV0FBTWlTcXJGTnhZWEVjcFZ5SXJHUEZVNkUycFlRb1hGTGlQZytscnNSb0IzVytMYjFWelBTZFM5Qm9wVENSbXZ0Y3dmQjRrY0ZvRUFaV09WZk9DMkZQVW9wT09YYjk3b1VBNzlvNGdKbkxzVWJFdXZEaDlDV1F5VnJ3NkZDTUJTY0NvQll6bytwN1RaQkJXaTZGUUJkWU5Fd2NOYkdYMWJyL0xsQW0vVGUzT0I0Q0M1VXozcDdodXR2dUNuV283Nno3MkYrMy9xWGIrTGxwbXRmZTEyMjRBQmtRQUF3S2EvQkEwanVnK3FWSVF4OW5nTUlqVnR6SWJHOGtSTkovYStEU0R5ZkJibVJOSzlsYmlTZHV4eld4SDRaVkxJd2g5S2lWTkdGaFNXNUZLVVV0QlBDSGdCR1RWMEtrTCtkTGNGS1pZQnd3WUgwUWdXWWhqK1VVeUdveFBQaDBHSEgxSGJyNTE4RGl6SGhYSUtEc1JZMlFERnVnQXUvU05SM0JoZk5IQWgzTGd3dUJDSGZOdFdsY2orL2RQdmRXb3ZycnI4eGU4TWRmOHE2ckZ1S2p2M2J2MzVyVXJaV1YxMTlMWUMwa0VtVE93SDhkNjRBUm5aTXFEY0hEZ0JkOEtEK3l4REd0NU1kQ05BR0NLOHJoVEtUY3I0b1Z6Z1IzbDlQU0VQMWwvd1ppL0o2bEc2RWo1WEJSSVgvRS90Ym9XeVRXWFFUYXVKUy9BTFgrVXVvaGRBSHprMmhvalVjVUlXS01TYUdQd1FWQUZCYmxZVS9QQTlqNkh3SUhEcTBZVzdGRnlld21BQ0xPYkJRTzZzVWRQaXNsZkt3WVE3TEJZZWhXUmhEaTU3MnNCaHRNc0RRZFFHUVFZYTM5Wi96TnJ3ZXpVODZYdlpEZXQxMXIxLzhaanhKSlRCSVN0ZkJJc0VDeUlGUjl0RUxEZ0JWNTBISFd1NkRQcGQzWkdJZElZeWhNWE5JMVYwSXpZY1daM2JYWkZjUThRY25FSW5qTE1pTnBEWUpLcnhzczlrWWJMbGpvUDlvWVJMMEtzYmNUZmpRUjlIN1kxRWthSjNOUXA5ZXFFamhUNG9IMDJJbHNCQlVBRFRkQ29BSUZzcHY5SURGQU9FMUJ4ckdlQkRBV2NCb2JLM0xYQXZCaFVCaXFYMkFSNnhYdUJjQThWV1ZUdWNPcElRTU1BVU43eWQ5WjBBUkhvbm9oWWYweUVLUEpMQVFJR0pmN0prT0NSYkFGQmk4bnhZNHFMd1dKcFh3b0xMU2ZWQy9zWTdnUUhpZEVpRFVmNjhMb1hPZFM2enljMWpxUk9JY2hFMW96YjhqWEVDRUgxZEt3UmlORGNLSExTVkx3NktpZnhUNnhPTXFoVDRUcUZDSUlrRUZlZmdEY2hURm44eWdDMFJRQVpDQnhZZEJhTG9WZmg1QUFSWmp2RHZRRGdZQkhEYUhDSGNoTWx5Y0NCYytWODNhOHdjTmFUNUFnZ0J0ODY5QkJwdDBMSE1lM0oxc0M2QVV6M0hYQUxMTFA1ZFJ1aERTSk14aGo4WlBkbDhXc09EOUdnNmFTdjVFQWdjZG45VHRnQWZWbThDaFBGNXhJTHplSEVEODNOb3VKSjRMa05mcENHZm8rczVCQk1oQklZVTBIQ0tod0NkZ25YTXA1eUJBaFNkb2U2RWloVDh3Q3NhNVBLY0NCV05VbHFnbHQwSy9UdHl0QU1tRmFNMitCN0FZK0h6RXJHUHhBM2x3bUxEUEpyZ1dZMW5JMGdFWEFEQWhYeE1tREFBWllHaHMvbjBPTWh1aERTLzNZMHlCRStzVnpzS1AxUWVQRWs2azNwY05sMDRFbURxYkVqNHRXQUR0L0FwZmFMeWRDQTVmTVlPQ1B6Y2p0bXZCbzZ3amhUQTB2eDZBOEhGNlhBaUFyc1FxbjA4cm5NbjY3NFFJSGR2d3lyMVF5Y0lmYW9lVVV5R28wRUFFRlFEUnJSQlVnUFMzaTAyNEV0eXRBUGx0WlQ0bjdrSktzRkEvVWlnRUpBZXhDVERscm9YZ0VqZllGWENKN29iQkJmQkFBWExBQUZnTkdYKzk1QnlJQkp4WVZrQ2s1ajYybGVPeDQ0VXl1ZzZhRWh3bFdMUUFrdko0Q1F6ZVR3c2FmRzV6cnFPc3krRkI0N1RjUi93c0FXVUZRQUFzZGlGcER2WGRxM3lNTW5TWkMyZkN3ZXc0QUd5TTB2RDNGZkk3SEJ3cVplaEQ1WlNvamJBSnUwa0pLZ0FpV09LRmNIa0lCQ0J6SzBBRExMUm5CQWhKVEQzNUM0TCtoQk1Nak5hZ1B3Q210c3BEajlXbnhib0VMandzQXV0UEFrenVZRXc4WDZvZk9nemZtZnNRSU5LQ1JQbmtkMDlZVStQRlhFNmxsbXd0SllVOWNqNUZ6cDlJcm9RZk55enYwZ01OM284RURpcm40SmpVcmNDRHpxTk1vZ0tJT1pEWWY4TzlyQUVJV0YzSmhmakxrMEladXM0bERNckVLdS9YbVB6YzR6K2t0aHR0d2o0VDZ5SlVPRHl5SkdhUnFNMUNJSGJNS1JVM2ZuRzM0cis3NkZZQVpHRFpoUFpMd2VMN25ib09DdUdpYTlIcEJVMDljQUhnZDhIU1FpMGcwQXNZQUhEaGw1ckdBYVpPWmlPMEM1M0hqeHc0ZkJ6ZWI2MThVcmJEZkFtcGxqY3BuUWpRdm1Vc2djSWZGMXdKdTdaTG9PRzdidGN2d3hhcW82VmpEWGpFT2tJZlVnamp6NnNDRUtDWkMvSGY2eTZFajZPVkFBaDJqQ0F5Y1Nrb1FSWENIS1g4eHJNSUZlZmlNUTRQN2xhY3k2R2oyQ0xrYm9IQTRrSStJZ3VENktMT2dBV29oMEkwcHRQK21SNkh1bXNodU1SakJWd28zNUxsVjBJLzhkeTRlL0VIL2Z3S2lQQVFpWC9uZFZLK1l6TnB4OEVDeU1BaFRjS1ZTbDZrQkZGTkxRQ1JKRGhJeXFBUUcwK0JJMEdDMUlKRldmOGcwQkRiVlZ5SG45YzhQTXBqa3Z1Z3VtVU9oTnBLRHNUM0pRUEUxNnNuVkdOZENSSktEbVZpSFpRNUY1WXpvUXNVUXhqRndFQnVSYm1zVHE5YkFaQVdkRmlBRVN6YWw4MkJCVHBBSllRUC9POGVtNDJPODhqZ0VpQW53WVhteU9IQ2oxRllSSDBDbUFDR3pvdmFBVWc3WXl1UTRXVWxhUGl4ckEvSXdDbnJTNER4ODk1bTMzbFkwd09NdGFxQmhrT2hWcmZ0VnRvaEVML2RQQWNOWHJZVUhMN050RTR0OUtuQmcvcU5kN2RZKzU0UUp2Vi9NSURRNTVZTDRXTkpBRkpLWVdPMDhWdStWWjRYQVpDNUZRQVJMSHdCN2hvc1FBcUZEUEtIQW5QWG9vQXdyeEl1SEFMK1BISzQrREdtSVEwUFVTYnVSQUNNMUxiczB4VDloZzhndFdBREJGQlVvSlAxS2ZTSHphWmFyNmJlUFNpOWVSTkFob3VVckcyQlJRSkYyV1lwTU1yK2xvS2piRHU1b3lUVlh3QVBQODlwRXBXdVJ4bkM4TFlsUUtReURoQ3h2T0ZDK0hmYU1PaHpKclNSeTdsMEd4ZTVXd0dRNVZZQVZNTWdvQTZXTkU2NlU1UEt3bFoxRlJaK2NDM2tLbXF1WlE0dXdEUXNRdGlhNzh0azkwSmo4ZTg4UlBMbGJjandQbmlTdEhRMFpSczBJRkdDb1lRUGtBT28xQzUydkxiVUFvMFN3cDZXR3dFSzhBaWdLUHRZQWd3Z2g0WlV0eGNjVkZhRFVnc2VxZDgrOTBGalN5NmoxNEZJWlZJdXBBYWo5TDRYLzkzZkdpYWdRL2x0NTlGSlRJSGc0a0puWUZGK01jMDVsdGlIWTMwSXJrWEg0eVk2Q0FrdUUxY1Fub0dKY1BFZHgyU3VpdHZqWmZjQ0lBTU1rSktVU3lIajYrU2d5Y2FyaEN3U2NJRGM0VWpsZEY2U21yZUFXLzNOcURkdkFsUnVHd3Z0bSs2a0xHdkFBaEFXTTZiQTRHM21YTXhTY0ZCZlBHazZhZGNCajNSTVQ5cG5ZR3NzL0I0SDRzK3hEeUJVbDErblNjNEVPbDJRcFdDQkNsQ1FRaUgvSmU1ZjZYSXR6dDhaU21Pa2tFaUNpeFFXUVlQMW5ic1hmeUVTWUFEQllTeUVERkFIRFNERGhvL0x4eTdiOHI3ajl3b0FTaEMxK2pnTU5kMkpBSkh5dmFKbEh4T1FDTTdDdDZuRGduOFc0ZE9BUnRaMklUaXl0a1hZUXVQdUNoNzB1VXlpVHVwMEFDU05Wd2RJa1RNSm9VT1pMK2tBQzlXanVpa1Brb09GMTh2MmNhRHRXdWhZR1JLbDhxbHpBUUJuODNNQ2N2Y0NGSUR4bGYxOERnZ1pQMzQ5ck9GektZL0h6eHNCR3VVdDRYSzdmR1hINnE1ekphVzY5NXhVWEl5MG81YkR3WS9SNTFhV2hEMDFZR1QxRzlBQXNCb2Nmdnc4NTVITnFRRVBxYnpsUHVMbklvbks2OGloVWg5QVVwOGFHM3FyZTN3Zkt2SkZXSE1zVkViYnN6TVE2U0tVNFhEQk1yZ0FZR0VQQ3llS3NDajJhZTNFdlFBNVlPTGNDd2VUUXFSdzU4Z1grbm5OUUFhWWdvYnZZZkYxdzgvR1pyTWFkTXF5eVhkMnE2WUZodFlmUXR1MVNnamtaZk1KV09sWURTVFZqVzlDWDNPaDBCcG84UG9TT1B4eExZS0RmazdhRlF1OVBGYkNZOUpHY0IrOFR1aytZaGwvQ0hNR0lMeHNvNVNHVW1IUlpjbFF4UC9ybDJCUkpyd0hCQW9hR3M0eUJ6RGpXbnkvZWE2RjE1WGdrdlVWUGtzNUZ3RHg3V1ljTVB4YzVnQURGRG1NR1NjRENLRHhGZjBQS1h4aHQ2Ukw0UGcyN0hOSFdOTnlIMnVmRUY2aTN0eEp6Y1gwaEQyVDdmaU45cjJ3NEhNcWdjSDdXUU9OL0xpYzBLMjVEdnBaSG1zNUR6ckhGanpLejVMN2FOZFhRdEkzL1hlSkQvb3BSYTgzOUFYTzJXSW5hMzRydFJVTzBYSHVXckoydW5BYlBjN0ZId1JRZHkvbFdCa2tDZ2RENXdSd21PU1F5Y3NLMEJpa2wwcVhvUEVONDBlQ2pUSEY0czVjU3lWc1lUbXRNR3oydlZRTENZZXg0N1dsMm03WVdDNGQ2M0VubFh4SnFKenFzZkZic09DZlMyRGtaZXVoa2RYckJVYzRIN0UrcG5PYTY3ZlhmYVQ2NmJ4cUFPRnROa3I3aVhubmtFQUE2QXdzK1YyV0JseEM4cFc3RnFyVGdrc3RMQUl3dVZOMEVNQUE4eTZHemkrcjJ3RWFRSWFOenVyb0hBUUNkQUFCUE94Y3MwTU5mRXp5S0pYRmZSQlhRdXAySjVXbmppZFE4SjNtYll2NW03TEpEQ2g4bFRvc2ZMbjBmK28rYVBEUGMza08vcE9EbzFxM2t2Tm85ZDBEajdKOVBmRXJBRVNsNzFwcGJPSjdTS043dC9BUmlBSWNtcTRsTFg0NTF3SzA0VUxqVGRwb0lWZGlDeEIwQWlicnR4TXlmZzc5b0tGcjRtVW1jeTNiQTNYbzVIVzEyQmFWdk1lMk9NdzNuSjZKT3ppUzVoSzBFeUFBR1JSSXJWREdOMG5qOU9WVjZyRHc1ZE83UFhOT1E2cXoxSEVBNkhZZDlMTjBWWWNHRHorNXlYZzB4NDNXQms2bFJhZXR6c0F5NTFvQXhIZWNTbkNoTmh3dXFSOTB1UmMvaG5DblJ3QU1iNk9LRUdFT01rWTRscm1Pd3RGSTlmMjhhdnRHK0lKTzBDbnJsZjBCQlF3NEpGaWZmTmxPM2NiaHVSSlNyenVwMVpQZWdUS1hxSjNtVnFhUUtPdlZua3p1Y1JqOGMrMG5JTHNOOGVlTTQ2ajluTDV4Ymo1c1NlUE13eVArTE54SHJKUGRFZkxITjBZcldMTHVDbkE2L2RJcnB5YXVoZUNDdUQ3bjRRSk13eUw2S1NWMGZmMDhxVnUyOCtOVW5NWkt5QUIxMFBCeGF1VVJDT3pWakpNN09pVTBaaEtzM1BId2N5dFYyMjlTRy9kTWFnNHkwaDRUYWFlc0ttRFRTdEsybkl3WTFzeEFSVHBXQTRiVVRuSWJZcjBaeDhIYnpyVnYzM0plQmc4YWw3Zm5WMWhyK2xNWHpDMVk2K0pBVGlIa00rak5ZdE9RU0lJTGhVVWtubk9oT3JWTlh5VmdBR1NBNFhWM0JSbGVad0lhL3dWQUFrMVozZ01PRVRwQUJoNnBqOGxjK1BGSzZESUhsdFR2YmtPZmNySFhKTUVqOXJIeWxuSFRyY3lBb3ZiWjhQek9BbUR3T1N4eEc3N2RPbkRRbVBVazhPN2hRZTJvenY4RG5nRS95eTZSUUpZQUFBQUFTVVZPUks1Q1lJST0iIHRyYW5zZm9ybT0ibWF0cml4KDEsMCwwLC0xLDAsMSkiIGhlaWdodD0iMSIgd2lkdGg9IjEiIHByZXNlcnZlQXNwZWN0UmF0aW89Im5vbmUiLz4KICAgIDwvZz4KICAgPC9nPgogIDwvZz4KIDwvZz4KPC9zdmc+Cg==\"],\"markerImageFunction\":\"var res = {\\n url: images[0],\\n size: 40\\n}\\nvar temperature = data[''temperature''];\\nif (typeof temperature !== undefined) {\\n var percent = (temperature + 60)/120;\\n var index = Math.floor(4 * percent);\\n res.url = images[index];\\n}\\nreturn res;\"}],\"fitMapBounds\":true},\"title\":\"OpenStreetMap\"}"}', -'OpenStreetMap' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'analogue_gauges', -'speed_gauge_canvas_gauges', -'{"type":"latest","sizeX":7,"sizeY":5,"resources":[],"templateHtml":"\n","templateCss":"","controllerScript":"self.onInit = function() {\n self.ctx.gauge = new TbAnalogueRadialGauge(self.ctx, ''radialGauge''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.gauge.update();\n}\n\nself.onResize = function() {\n self.ctx.gauge.resize();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.gauge.mobileModeChanged();\n}\n\nself.getSettingsSchema = function() {\n return TbAnalogueRadialGauge.settingsSchema;\n}\n\nself.onDestroy = function() {\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Speed\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.7282710489093589,\"funcBody\":\"var value = prevValue + Math.random() * 50 - 25;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 220) {\\n\\tvalue = 220;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgb(255, 255, 255)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"maxValue\":180,\"startAngle\":45,\"ticksAngle\":270,\"showBorder\":false,\"defaultColor\":\"#e65100\",\"needleCircleSize\":7,\"highlights\":[{\"from\":80,\"to\":120,\"color\":\"#fdd835\"},{\"color\":\"#e57373\",\"from\":120,\"to\":180}],\"showUnitTitle\":false,\"colorPlate\":\"#fff\",\"colorMajorTicks\":\"#444\",\"colorMinorTicks\":\"#666\",\"minorTicks\":2,\"valueInt\":3,\"minValue\":0,\"valueDec\":0,\"highlightsWidth\":15,\"valueBox\":true,\"animation\":true,\"animationDuration\":1500,\"animationRule\":\"linear\",\"colorNeedleShadowUp\":\"rgba(2, 255, 255, 0)\",\"colorNeedleShadowDown\":\"rgba(188, 143, 143, 0.78)\",\"units\":\"MPH\",\"majorTicksCount\":9,\"numbersFont\":{\"family\":\"Roboto\",\"size\":22,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"titleFont\":{\"family\":\"Roboto\",\"size\":24,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#888\"},\"unitsFont\":{\"family\":\"Roboto\",\"size\":28,\"style\":\"normal\",\"weight\":\"500\",\"color\":\"#616161\"},\"valueFont\":{\"size\":32,\"style\":\"normal\",\"weight\":\"normal\",\"shadowColor\":\"rgba(0, 0, 0, 0.49)\",\"color\":\"#444\",\"family\":\"Segment7Standard\"},\"colorValueBoxRect\":\"#888\",\"colorValueBoxRectEnd\":\"#666\",\"colorValueBoxBackground\":\"#babab2\",\"colorValueBoxShadow\":\"rgba(0,0,0,1)\"},\"title\":\"Speed gauge - Canvas Gauges\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Speed gauge - Canvas Gauges' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'pie', -'{"type":"latest","sizeX":8,"sizeY":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.pie-label {\n font-size: 12px;\n font-family: ''Roboto'';\n font-weight: bold;\n text-align: center;\n padding: 2px;\n color: white;\n}\n","controllerScript":"self.onInit = function() {\n self.ctx.flot = new TbFlot(self.ctx, ''pie''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.pieSettingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.pieDatakeySettingsSchema;\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n","settingsSchema":"{}\n","dataKeySettingsSchema":"{}\n","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#4caf50\",\"settings\":{},\"_hash\":0.6114638304362894,\"funcBody\":\"var value = (prevValue-20) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+20;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Third\",\"color\":\"#f44336\",\"settings\":{},\"_hash\":0.9955906536344441,\"funcBody\":\"var value = (prevValue-40) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+40;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Fourth\",\"color\":\"#ffc107\",\"settings\":{},\"_hash\":0.9430835931647599,\"funcBody\":\"var value = (prevValue-50) + Math.random() * 2 - 1;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 100) {\\n\\tvalue = 100;\\n}\\nreturn value+50;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"radius\":1,\"fontColor\":\"#545454\",\"fontSize\":10,\"decimals\":1,\"legend\":{\"show\":true,\"position\":\"nw\",\"labelBoxBorderColor\":\"#CCCCCC\",\"backgroundColor\":\"#F0F0F0\",\"backgroundOpacity\":0.85},\"innerRadius\":0,\"showLabels\":true,\"stroke\":{\"width\":5},\"tilt\":1,\"animatedPie\":false},\"title\":\"Pie - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}"}', -'Pie - Flot' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'timeseries_bars_flot', -'{"type":"timeseries","sizeX":8,"sizeY":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 self.ctx.flot = new TbFlot(self.ctx, ''bar''); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(false);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.8587686344902596,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":false,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000},\"aggregation\":{\"limit\":200,\"type\":\"AVG\"}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":true,\"tooltipIndividual\":false},\"title\":\"Timeseries Bars - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', -'Timeseries Bars - Flot' ); - -INSERT INTO "thingsboard"."widget_type" ( "id", "tenant_id", "bundle_alias", "alias", "descriptor", "name" ) -VALUES ( now ( ), minTimeuuid ( 0 ), 'charts', 'basic_timeseries', -'{"type":"timeseries","sizeX":8,"sizeY":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 self.ctx.flot = new TbFlot(self.ctx); \n}\n\nself.onDataUpdated = function() {\n self.ctx.flot.update();\n}\n\nself.onResize = function() {\n self.ctx.flot.resize();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.onMobileModeChanged = function() {\n self.ctx.flot.checkMouseEvents();\n}\n\nself.getSettingsSchema = function() {\n return TbFlot.settingsSchema;\n}\n\nself.getDataKeySettingsSchema = function() {\n return TbFlot.datakeySettingsSchema(true);\n}\n\nself.onDestroy = function() {\n self.ctx.flot.destroy();\n}\n","settingsSchema":"{}","dataKeySettingsSchema":"{}","defaultConfig":"{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"First\",\"color\":\"#2196f3\",\"settings\":{\"showLines\":true,\"fillLines\":true,\"showPoints\":false},\"_hash\":0.8587686344902596,\"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;\"},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Second\",\"color\":\"#ffc107\",\"settings\":{\"showLines\":true,\"fillLines\":false,\"showPoints\":false},\"_hash\":0.12775350966079668,\"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;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"shadowSize\":4,\"fontColor\":\"#545454\",\"fontSize\":10,\"xaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"yaxis\":{\"showLabels\":true,\"color\":\"#545454\"},\"grid\":{\"color\":\"#545454\",\"tickColor\":\"#DDDDDD\",\"verticalLines\":true,\"horizontalLines\":true,\"outlineWidth\":1},\"legend\":{\"show\":true,\"position\":\"nw\",\"backgroundColor\":\"#f0f0f0\",\"backgroundOpacity\":0.85,\"labelBoxBorderColor\":\"rgba(1, 1, 1, 0.45)\"},\"decimals\":1,\"stack\":false,\"tooltipIndividual\":false},\"title\":\"Timeseries - Flot\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"mobileHeight\":null}"}', -'Timeseries - Flot' ); - -/** SYSTEM **/ +}' ); \ No newline at end of file diff --git a/dao/src/main/resources/sql/schema.sql b/dao/src/main/resources/sql/schema-entities.sql similarity index 90% rename from dao/src/main/resources/sql/schema.sql rename to dao/src/main/resources/sql/schema-entities.sql index 91e77da503..fc23832d56 100644 --- a/dao/src/main/resources/sql/schema.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -78,7 +78,7 @@ CREATE TABLE IF NOT EXISTS attribute_kv ( CREATE TABLE IF NOT EXISTS component_descriptor ( id varchar(31) NOT NULL CONSTRAINT component_descriptor_pkey PRIMARY KEY, actions varchar(255), - clazz varchar, + clazz varchar UNIQUE, configuration_descriptor varchar, name varchar(255), scope varchar(255), @@ -179,30 +179,6 @@ CREATE TABLE IF NOT EXISTS tenant ( zip varchar(255) ); -CREATE TABLE IF NOT EXISTS ts_kv ( - entity_type varchar(255) NOT NULL, - entity_id varchar(31) NOT NULL, - key varchar(255) NOT NULL, - ts bigint NOT NULL, - bool_v boolean, - str_v varchar(10000000), - long_v bigint, - dbl_v double precision, - CONSTRAINT ts_kv_unq_key UNIQUE (entity_type, entity_id, key, ts) -); - -CREATE TABLE IF NOT EXISTS ts_kv_latest ( - entity_type varchar(255) NOT NULL, - entity_id varchar(31) NOT NULL, - key varchar(255) NOT NULL, - ts bigint NOT NULL, - bool_v boolean, - str_v varchar(10000000), - long_v bigint, - dbl_v double precision, - CONSTRAINT ts_kv_latest_unq_key UNIQUE (entity_type, entity_id, key) -); - CREATE TABLE IF NOT EXISTS user_credentials ( id varchar(31) NOT NULL CONSTRAINT user_credentials_pkey PRIMARY KEY, activate_token varchar(255) UNIQUE, @@ -251,3 +227,18 @@ CREATE TABLE IF NOT EXISTS rule_node ( debug_mode boolean, search_text varchar(255) ); + +CREATE TABLE IF NOT EXISTS entity_view ( + id varchar(31) NOT NULL CONSTRAINT entity_view_pkey PRIMARY KEY, + entity_id varchar(31), + entity_type varchar(255), + tenant_id varchar(31), + customer_id varchar(31), + type varchar(255), + name varchar(255), + keys varchar(255), + start_ts bigint, + end_ts bigint, + search_text varchar(255), + additional_info varchar +); diff --git a/dao/src/main/resources/sql/schema-ts.sql b/dao/src/main/resources/sql/schema-ts.sql new file mode 100644 index 0000000000..53bc15acc3 --- /dev/null +++ b/dao/src/main/resources/sql/schema-ts.sql @@ -0,0 +1,39 @@ +-- +-- Copyright © 2016-2018 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +CREATE TABLE IF NOT EXISTS ts_kv ( + entity_type varchar(255) NOT NULL, + entity_id varchar(31) NOT NULL, + key varchar(255) NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + CONSTRAINT ts_kv_unq_key UNIQUE (entity_type, entity_id, key, ts) +); + +CREATE TABLE IF NOT EXISTS ts_kv_latest ( + entity_type varchar(255) NOT NULL, + entity_id varchar(31) NOT NULL, + key varchar(255) NOT NULL, + ts bigint NOT NULL, + bool_v boolean, + str_v varchar(10000000), + long_v bigint, + dbl_v double precision, + CONSTRAINT ts_kv_latest_unq_key UNIQUE (entity_type, entity_id, key) +); diff --git a/dao/src/test/java/org/thingsboard/server/dao/JpaDaoTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/JpaDaoTestSuite.java index 48ba2fae68..a9d2cbc4f9 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/JpaDaoTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/JpaDaoTestSuite.java @@ -30,7 +30,7 @@ public class JpaDaoTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql"), "sql/drop-all-tables.sql", "sql-test.properties" ); diff --git a/dao/src/test/java/org/thingsboard/server/dao/NoSqlDaoServiceTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/NoSqlDaoServiceTestSuite.java index f10462d77f..55c2f70f1d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/NoSqlDaoServiceTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/NoSqlDaoServiceTestSuite.java @@ -34,7 +34,9 @@ public class NoSqlDaoServiceTestSuite { @ClassRule public static CustomCassandraCQLUnit cassandraUnit = new CustomCassandraCQLUnit( - Arrays.asList(new ClassPathCQLDataSet("cassandra/schema.cql", false, false), + Arrays.asList( + new ClassPathCQLDataSet("cassandra/schema-ts.cql", false, false), + new ClassPathCQLDataSet("cassandra/schema-entities.cql", false, false), new ClassPathCQLDataSet("cassandra/system-data.cql", false, false), new ClassPathCQLDataSet("cassandra/system-test.cql", false, false)), "cassandra-test.yaml", 30000L); diff --git a/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java b/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java index 3f65184bfb..9e56d64aba 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java +++ b/dao/src/test/java/org/thingsboard/server/dao/SqlDaoServiceTestSuite.java @@ -30,7 +30,7 @@ public class SqlDaoServiceTestSuite { @ClassRule public static CustomSqlUnit sqlUnit = new CustomSqlUnit( - Arrays.asList("sql/schema.sql", "sql/system-data.sql", "sql/system-test.sql"), + Arrays.asList("sql/schema-ts.sql", "sql/schema-entities.sql", "sql/system-data.sql", "sql/system-test.sql"), "sql/drop-all-tables.sql", "sql-test.properties" ); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java index 1e1f15daff..f49c357caa 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java @@ -45,6 +45,7 @@ import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.device.DeviceCredentialsService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; @@ -88,6 +89,9 @@ public abstract class AbstractServiceTest { @Autowired protected AssetService assetService; + @Autowired + protected EntityViewService entityViewService; + @Autowired protected DeviceCredentialsService deviceCredentialsService; diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 4528d9afb1..81de40ac82 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -17,9 +17,15 @@ package org.thingsboard.server.dao.service.timeseries; import com.datastax.driver.core.utils.UUIDs; import lombok.extern.slf4j.Slf4j; +import org.junit.After; import org.junit.Assert; +import org.junit.Before; import org.junit.Test; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.Aggregation; import org.thingsboard.server.common.data.kv.BaseDeleteTsKvQuery; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; @@ -30,6 +36,7 @@ import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.common.data.objects.TelemetryEntityView; import org.thingsboard.server.dao.service.AbstractServiceTest; import java.util.ArrayList; @@ -61,6 +68,22 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { KvEntry doubleKvEntry = new DoubleDataEntry(DOUBLE_KEY, Double.MAX_VALUE); KvEntry booleanKvEntry = new BooleanDataEntry(BOOLEAN_KEY, Boolean.TRUE); + private TenantId tenantId; + + @Before + public void before() { + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + Tenant savedTenant = tenantService.saveTenant(tenant); + Assert.assertNotNull(savedTenant); + tenantId = savedTenant.getId(); + } + + @After + public void after() { + tenantService.deleteTenant(tenantId); + } + @Test public void testFindAllLatest() throws Exception { DeviceId deviceId = new DeviceId(UUIDs.timeBased()); @@ -69,7 +92,15 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { saveEntries(deviceId, TS - 1); saveEntries(deviceId, TS); - List tsList = tsService.findAllLatest(deviceId).get(); + testLatestTsAndVerify(deviceId); + + EntityView entityView = saveAndCreateEntityView(deviceId, Arrays.asList(STRING_KEY, DOUBLE_KEY, LONG_KEY, BOOLEAN_KEY)); + + testLatestTsAndVerify(entityView.getId()); + } + + private void testLatestTsAndVerify(EntityId entityId) throws ExecutionException, InterruptedException { + List tsList = tsService.findAllLatest(entityId).get(); assertNotNull(tsList); assertEquals(4, tsList.size()); @@ -89,6 +120,18 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { assertEquals(expected, tsList); } + private EntityView saveAndCreateEntityView(DeviceId deviceId, List timeseries) { + EntityView entityView = new EntityView(); + entityView.setName("entity_view_name"); + entityView.setType("default"); + entityView.setTenantId(tenantId); + TelemetryEntityView keys = new TelemetryEntityView(); + keys.setTimeseries(timeseries); + entityView.setKeys(keys); + entityView.setEntityId(deviceId); + return entityViewService.saveEntityView(entityView); + } + @Test public void testFindLatest() throws Exception { DeviceId deviceId = new DeviceId(UUIDs.timeBased()); @@ -100,10 +143,16 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { List entries = tsService.findLatest(deviceId, Collections.singleton(STRING_KEY)).get(); Assert.assertEquals(1, entries.size()); Assert.assertEquals(toTsEntry(TS, stringKvEntry), entries.get(0)); + + EntityView entityView = saveAndCreateEntityView(deviceId, Arrays.asList(STRING_KEY)); + + entries = tsService.findLatest(entityView.getId(), Collections.singleton(STRING_KEY)).get(); + Assert.assertEquals(1, entries.size()); + Assert.assertEquals(toTsEntry(TS, stringKvEntry), entries.get(0)); } @Test - public void testDeleteDeviceTsData() throws Exception { + public void testDeleteDeviceTsDataWithoutOverwritingLatest() throws Exception { DeviceId deviceId = new DeviceId(UUIDs.timeBased()); saveEntries(deviceId, 10000); @@ -122,6 +171,26 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { Assert.assertEquals(null, latest.get(0).getValueAsString()); } + @Test + public void testDeleteDeviceTsDataWithOverwritingLatest() throws Exception { + DeviceId deviceId = new DeviceId(UUIDs.timeBased()); + + saveEntries(deviceId, 10000); + saveEntries(deviceId, 20000); + saveEntries(deviceId, 30000); + saveEntries(deviceId, 40000); + + tsService.remove(deviceId, Collections.singletonList( + new BaseDeleteTsKvQuery(STRING_KEY, 25000, 45000, true))).get(); + + List list = tsService.findAll(deviceId, Collections.singletonList( + new BaseReadTsKvQuery(STRING_KEY, 5000, 45000, 10000, 10, Aggregation.NONE))).get(); + Assert.assertEquals(2, list.size()); + + List latest = tsService.findLatest(deviceId, Collections.singletonList(STRING_KEY)).get(); + Assert.assertEquals(20000, latest.get(0).getTs()); + } + @Test public void testFindDeviceTsData() throws Exception { DeviceId deviceId = new DeviceId(UUIDs.timeBased()); diff --git a/dao/src/test/java/org/thingsboard/server/dao/util/BufferedRateLimiterTest.java b/dao/src/test/java/org/thingsboard/server/dao/util/BufferedRateLimiterTest.java deleted file mode 100644 index 366cefd5d8..0000000000 --- a/dao/src/test/java/org/thingsboard/server/dao/util/BufferedRateLimiterTest.java +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.util; - -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import org.junit.Test; -import org.thingsboard.server.dao.exception.BufferLimitException; - -import javax.annotation.Nullable; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; -import static org.junit.Assert.fail; - - -public class BufferedRateLimiterTest { - - @Test - public void finishedFutureReturnedIfPermitsAreGranted() { - BufferedRateLimiter limiter = new BufferedRateLimiter(10, 10, 100); - ListenableFuture actual = limiter.acquireAsync(); - assertTrue(actual.isDone()); - } - - @Test - public void notFinishedFutureReturnedIfPermitsAreNotGranted() { - BufferedRateLimiter limiter = new BufferedRateLimiter(10, 1, 100); - ListenableFuture actual1 = limiter.acquireAsync(); - ListenableFuture actual2 = limiter.acquireAsync(); - assertTrue(actual1.isDone()); - assertFalse(actual2.isDone()); - } - - @Test - public void failedFutureReturnedIfQueueIsfull() { - BufferedRateLimiter limiter = new BufferedRateLimiter(1, 1, 100); - ListenableFuture actual1 = limiter.acquireAsync(); - ListenableFuture actual2 = limiter.acquireAsync(); - ListenableFuture actual3 = limiter.acquireAsync(); - - assertTrue(actual1.isDone()); - assertFalse(actual2.isDone()); - assertTrue(actual3.isDone()); - try { - actual3.get(); - fail(); - } catch (Exception e) { - assertTrue(e instanceof ExecutionException); - Throwable actualCause = e.getCause(); - assertTrue(actualCause instanceof BufferLimitException); - assertEquals("Rate Limit Buffer is full", actualCause.getMessage()); - } - } - - @Test - public void releasedPermitTriggerTasksFromQueue() throws InterruptedException { - BufferedRateLimiter limiter = new BufferedRateLimiter(10, 2, 100); - ListenableFuture actual1 = limiter.acquireAsync(); - ListenableFuture actual2 = limiter.acquireAsync(); - ListenableFuture actual3 = limiter.acquireAsync(); - ListenableFuture actual4 = limiter.acquireAsync(); - assertTrue(actual1.isDone()); - assertTrue(actual2.isDone()); - assertFalse(actual3.isDone()); - assertFalse(actual4.isDone()); - limiter.release(); - TimeUnit.MILLISECONDS.sleep(100L); - assertTrue(actual3.isDone()); - assertFalse(actual4.isDone()); - limiter.release(); - TimeUnit.MILLISECONDS.sleep(100L); - assertTrue(actual4.isDone()); - } - - @Test - public void permitsReleasedInConcurrentMode() throws InterruptedException { - BufferedRateLimiter limiter = new BufferedRateLimiter(10, 2, 100); - AtomicInteger actualReleased = new AtomicInteger(); - AtomicInteger actualRejected = new AtomicInteger(); - ListeningExecutorService pool = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(5)); - for (int i = 0; i < 100; i++) { - ListenableFuture> submit = pool.submit(limiter::acquireAsync); - Futures.addCallback(submit, new FutureCallback>() { - @Override - public void onSuccess(@Nullable ListenableFuture result) { - Futures.addCallback(result, new FutureCallback() { - @Override - public void onSuccess(@Nullable Void result) { - try { - TimeUnit.MILLISECONDS.sleep(100); - } catch (InterruptedException e) { - e.printStackTrace(); - } - limiter.release(); - actualReleased.incrementAndGet(); - } - - @Override - public void onFailure(Throwable t) { - actualRejected.incrementAndGet(); - } - }); - } - - @Override - public void onFailure(Throwable t) { - } - }); - } - - TimeUnit.SECONDS.sleep(2); - assertTrue("Unexpected released count " + actualReleased.get(), - actualReleased.get() > 10 && actualReleased.get() < 20); - assertTrue("Unexpected rejected count " + actualRejected.get(), - actualRejected.get() > 80 && actualRejected.get() < 90); - - } - - -} \ No newline at end of file diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index 20cf91c3dd..3bd0f7d41a 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -24,6 +24,9 @@ caffeine.specs.devices.maxSize=100000 caffeine.specs.assets.timeToLiveInMinutes=1440 caffeine.specs.assets.maxSize=100000 +caffeine.specs.entityViews.timeToLiveInMinutes=1440 +caffeine.specs.entityViews.maxSize=100000 + caching.specs.devices.timeToLiveInMinutes=1440 caching.specs.devices.maxSize=100000 @@ -32,4 +35,4 @@ redis.connection.port=6379 redis.connection.db=0 redis.connection.password= -rule.queue.type=memory +database.ts_max_intervals=700 \ No newline at end of file diff --git a/dao/src/test/resources/nosql-test.properties b/dao/src/test/resources/nosql-test.properties index e37e228b40..7c3ec51e8a 100644 --- a/dao/src/test/resources/nosql-test.properties +++ b/dao/src/test/resources/nosql-test.properties @@ -1,6 +1,2 @@ -database.type=cassandra - -cassandra.queue.partitioning=HOURS -cassandra.queue.ack.ttl=3600 -cassandra.queue.msg.ttl=3600 -cassandra.queue.partitions.ttl=3600 \ No newline at end of file +database.entities.type=cassandra +database.ts.type=cassandra diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties index 1f34b98222..3357425fce 100644 --- a/dao/src/test/resources/sql-test.properties +++ b/dao/src/test/resources/sql-test.properties @@ -1,4 +1,5 @@ -database.type=sql +database.ts.type=sql +database.entities.type=sql sql.ts_inserts_executor_type=fixed sql.ts_inserts_fixed_thread_pool_size=10 diff --git a/dao/src/test/resources/sql/drop-all-tables.sql b/dao/src/test/resources/sql/drop-all-tables.sql index 23b6a56a47..1bdc1a7ece 100644 --- a/dao/src/test/resources/sql/drop-all-tables.sql +++ b/dao/src/test/resources/sql/drop-all-tables.sql @@ -18,4 +18,5 @@ DROP TABLE IF EXISTS user_credentials; DROP TABLE IF EXISTS widget_type; DROP TABLE IF EXISTS widgets_bundle; DROP TABLE IF EXISTS rule_node; -DROP TABLE IF EXISTS rule_chain; \ No newline at end of file +DROP TABLE IF EXISTS rule_chain; +DROP TABLE IF EXISTS entity_view; diff --git a/docker/.env b/docker/.env index e3306324d4..ef0bf40316 100644 --- a/docker/.env +++ b/docker/.env @@ -1,13 +1,18 @@ -# cassandra environment variables -CASSANDRA_DATA_DIR=/home/docker/cassandra_volume -# postgres environment variables -POSTGRES_DATA_DIR=/home/docker/postgres_volume -POSTGRES_DB=thingsboard +DOCKER_REPO=thingsboard -# hsqldb environment variables -HSQLDB_DATA_DIR=/home/docker/hsqldb_volume +JS_EXECUTOR_DOCKER_NAME=tb-js-executor +TB_NODE_DOCKER_NAME=tb-node +WEB_UI_DOCKER_NAME=tb-web-ui +MQTT_TRANSPORT_DOCKER_NAME=tb-mqtt-transport +HTTP_TRANSPORT_DOCKER_NAME=tb-http-transport +COAP_TRANSPORT_DOCKER_NAME=tb-coap-transport -# environment variables for schema init and insert system and demo data -ADD_SCHEMA_AND_SYSTEM_DATA=false -ADD_DEMO_DATA=false \ No newline at end of file +TB_VERSION=latest + +# Database used by ThingsBoard, can be either postgres (PostgreSQL) or cassandra (Cassandra). +# According to the database type corresponding docker service will be deployed (see docker-compose.postgres.yml, docker-compose.cassandra.yml for details). + +DATABASE=postgres + +LOAD_BALANCER_NAME=haproxy-certbot diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000000..bc09e846b0 --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1,7 @@ +haproxy/certs.d/** +haproxy/letsencrypt/** +tb-node/log/** +tb-node/db/** +tb-node/postgres/** +tb-node/cassandra/** +!.env diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000..c43a136d09 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,95 @@ +# Docker configuration for ThingsBoard Microservices + +This folder containing scripts and Docker Compose configurations to run ThingsBoard in Microservices mode. + +## Prerequisites + +ThingsBoard Microservices are running in dockerized environment. +Before starting please make sure [Docker CE](https://docs.docker.com/install/) and [Docker Compose](https://docs.docker.com/compose/install/) are installed in your system. + +## Installation + +Before performing initial installation you can configure the type of database to be used with ThinsBoard. +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 docker service will be deployed (see `docker-compose.postgres.yml`, `docker-compose.cassandra.yml` for details). + +Execute the following command to run installation: + +` +$ ./docker-install-tb.sh --loadDemo +` + +Where: + +- `--loadDemo` - optional argument. Whether to load additional demo data. + +## Running + +Execute the following command to start services: + +` +$ ./docker-start-services.sh +` + +After a while when all services will be successfully started you can open `http://{your-host-ip}` in you browser (for ex. `http://localhost`). +You should see ThingsBoard login page. + +Use the following default credentials: + +- **Systen 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 command: + +` +$ docker-compose logs -f tb1 +` + +Or use `docker-compose ps` to see the state of all the containers. +Use `docker-compose logs --f` to inspect the logs of all running services. +See [docker-compose logs](https://docs.docker.com/compose/reference/logs/) command reference for details. + +Execute the following command to stop services: + +` +$ ./docker-stop-services.sh +` + +Execute the following command to stop and completely remove deployed docker containers: + +` +$ ./docker-remove-services.sh +` + +Execute the following command to update particular or all services (pull newer docker image and rebuild container): + +` +$ ./docker-update-service.sh [SERVICE...] +` + +Where: + +- `[SERVICE...]` - list of services to update (defined in docker-compose configurations). If not specified all services will be updated. + +## Upgrading + +In case when database upgrade is needed, execute the following commands: + +``` +$ ./docker-stop-services.sh +$ ./docker-upgrade-tb.sh --fromVersion=[FROM_VERSION] +$ ./docker-start-services.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/docker/cassandra-setup/Makefile b/docker/cassandra-setup/Makefile deleted file mode 100644 index 7990a3e7e8..0000000000 --- a/docker/cassandra-setup/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -VERSION=2.1.0 -PROJECT=thingsboard -APP=cassandra-setup - -build: - cp ../../application/target/thingsboard.deb . - docker build --pull -t ${PROJECT}/${APP}:${VERSION} -t ${PROJECT}/${APP}:latest . - rm thingsboard.deb - -push: build - docker push ${PROJECT}/${APP}:${VERSION} - docker push ${PROJECT}/${APP}:latest diff --git a/docker/cassandra-upgrade/Makefile b/docker/cassandra-upgrade/Makefile deleted file mode 100644 index b2e8501a2b..0000000000 --- a/docker/cassandra-upgrade/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -VERSION=2.1.0 -PROJECT=thingsboard -APP=cassandra-upgrade - -build: - cp ../../application/target/thingsboard.deb . - docker build --pull -t ${PROJECT}/${APP}:${VERSION} -t ${PROJECT}/${APP}:latest . - rm thingsboard.deb - -push: build - docker push ${PROJECT}/${APP}:${VERSION} - docker push ${PROJECT}/${APP}:latest diff --git a/docker/cassandra/Makefile b/docker/cassandra/Makefile deleted file mode 100644 index d1fb67705f..0000000000 --- a/docker/cassandra/Makefile +++ /dev/null @@ -1,10 +0,0 @@ -VERSION=2.1.0 -PROJECT=thingsboard -APP=cassandra - -build: - docker build --pull -t ${PROJECT}/${APP}:${VERSION} -t ${PROJECT}/${APP}:latest . - -push: build - docker push ${PROJECT}/${APP}:${VERSION} - docker push ${PROJECT}/${APP}:latest diff --git a/docker/cassandra-upgrade/upgrade.sh b/docker/check-dirs.sh similarity index 68% rename from docker/cassandra-upgrade/upgrade.sh rename to docker/check-dirs.sh index dac49191b2..2042a44aea 100755 --- a/docker/cassandra-upgrade/upgrade.sh +++ b/docker/check-dirs.sh @@ -15,14 +15,12 @@ # limitations under the License. # +dirsArray=("./haproxy/certs.d" "./haproxy/letsencrypt" "./tb-node/postgres" "./tb-node/cassandra" "./tb-node/log/tb1" "./tb-node/log/tb2") -dpkg -i /thingsboard.deb - -until nmap $CASSANDRA_HOST -p $CASSANDRA_PORT | grep "$CASSANDRA_PORT/tcp open" +for dir in ${dirsArray[@]} do - echo "Wait for cassandra db to start..." - sleep 10 + if [ ! -d "$dir" ]; then + echo creating dir $dir + mkdir -p $dir + fi done - -echo "Upgrading 'Thingsboard' schema..." -/usr/share/thingsboard/bin/install/upgrade.sh --fromVersion=$UPGRADE_FROM_VERSION diff --git a/docker/compose-utils.sh b/docker/compose-utils.sh new file mode 100755 index 0000000000..d1dc20e540 --- /dev/null +++ b/docker/compose-utils.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +function additionalComposeArgs() { + source .env + ADDITIONAL_COMPOSE_ARGS="" + case $DATABASE in + postgres) + ADDITIONAL_COMPOSE_ARGS="-f docker-compose.postgres.yml" + ;; + cassandra) + ADDITIONAL_COMPOSE_ARGS="-f docker-compose.cassandra.yml" + ;; + *) + echo "Unknown DATABASE value specified: '${DATABASE}'. Should be either postgres or cassandra." >&2 + exit 1 + esac + echo $ADDITIONAL_COMPOSE_ARGS +} + +function additionalStartupServices() { + source .env + ADDITIONAL_STARTUP_SERVICES="" + case $DATABASE in + postgres) + ADDITIONAL_STARTUP_SERVICES=postgres + ;; + cassandra) + ADDITIONAL_STARTUP_SERVICES=cassandra + ;; + *) + echo "Unknown DATABASE value specified: '${DATABASE}'. Should be either postgres or cassandra." >&2 + exit 1 + esac + echo $ADDITIONAL_STARTUP_SERVICES +} diff --git a/docker/cluster-mode-thirdparty.yml b/docker/docker-compose.cassandra.yml similarity index 62% rename from docker/cluster-mode-thirdparty.yml rename to docker/docker-compose.cassandra.yml index 3aa5bb5959..721ea3ef42 100644 --- a/docker/cluster-mode-thirdparty.yml +++ b/docker/docker-compose.cassandra.yml @@ -14,32 +14,27 @@ # limitations under the License. # -version: '3.3' -services: - zookeeper: - image: wurstmeister/zookeeper - networks: - - core - ports: - - "2181:2181" +version: '2.2' +services: cassandra: - image: cassandra:3.11.2 - networks: - - core + restart: always + image: "cassandra:3.11.3" ports: - - "7199:7199" - - "9160:9160" - - "9042:9042" - - redis: - image: redis:4.0 - networks: - - core - command: redis-server --maxclients 2000 - ports: - - "6379:6379" - -networks: - core: - + - "9042" + volumes: + - ./tb-node/cassandra:/var/lib/cassandra + tb1: + env_file: + - tb-node.cassandra.env + depends_on: + - kafka + - redis + - cassandra + tb2: + env_file: + - tb-node.cassandra.env + depends_on: + - kafka + - redis + - cassandra diff --git a/docker/docker-compose.postgres.volumes.yml b/docker/docker-compose.postgres.volumes.yml new file mode 100644 index 0000000000..d94b066cb0 --- /dev/null +++ b/docker/docker-compose.postgres.volumes.yml @@ -0,0 +1,36 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '2.2' + +services: + postgres: + volumes: + - postgres-db-volume:/var/lib/postgresql/data + tb1: + volumes: + - tb-log-volume:/var/log/thingsboard + tb2: + volumes: + - tb-log-volume:/var/log/thingsboard + +volumes: + postgres-db-volume: + external: true + name: ${POSTGRES_DATA_VOLUME} + tb-log-volume: + external: true + name: ${TB_LOG_VOLUME} diff --git a/docker/docker-compose.postgres.yml b/docker/docker-compose.postgres.yml new file mode 100644 index 0000000000..c81359aac2 --- /dev/null +++ b/docker/docker-compose.postgres.yml @@ -0,0 +1,42 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +version: '2.2' + +services: + postgres: + restart: always + image: "postgres:9.6" + ports: + - "5432" + environment: + POSTGRES_DB: thingsboard + volumes: + - ./tb-node/postgres:/var/lib/postgresql/data + tb1: + env_file: + - tb-node.postgres.env + depends_on: + - kafka + - redis + - postgres + tb2: + env_file: + - tb-node.postgres.env + depends_on: + - kafka + - redis + - postgres diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 89a83696f3..b9d8fd1a54 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,40 +14,165 @@ # limitations under the License. # -version: '2' + +version: '2.2' services: - tb: - image: "thingsboard/application:2.1.0" + zookeeper: + restart: always + image: "zookeeper:3.5" ports: - - "8080:8080" - - "1883:1883" - - "5683:5683/udp" + - "2181" + kafka: + restart: always + image: "wurstmeister/kafka" + ports: + - "9092:9092" env_file: - - tb.env + - kafka.env + depends_on: + - zookeeper + redis: + image: redis:4.0 + ports: + - "6379" + tb-js-executor: + restart: always + image: "${DOCKER_REPO}/${JS_EXECUTOR_DOCKER_NAME}:${TB_VERSION}" + scale: 20 + env_file: + - tb-js-executor.env + depends_on: + - kafka + tb1: + restart: always + image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8080" + logging: + driver: "json-file" + options: + max-size: "200m" + max-file: "30" environment: - - ADD_SCHEMA_AND_SYSTEM_DATA=${ADD_SCHEMA_AND_SYSTEM_DATA} - - ADD_DEMO_DATA=${ADD_DEMO_DATA} + TB_HOST: tb1 + env_file: + - tb-node.env volumes: - - "${HSQLDB_DATA_DIR}:/usr/share/thingsboard/data/sql" - entrypoint: /run-application.sh - cassandra: - image: "cassandra:3.11.2" + - ./tb-node/conf:/config + - ./tb-node/log:/var/log/thingsboard + depends_on: + - kafka + - redis + - tb-js-executor + tb2: + restart: always + image: "${DOCKER_REPO}/${TB_NODE_DOCKER_NAME}:${TB_VERSION}" ports: - - "9042" - - "9160" + - "8080" + logging: + driver: "json-file" + options: + max-size: "200m" + max-file: "30" + environment: + TB_HOST: tb2 + env_file: + - tb-node.env volumes: - - "${CASSANDRA_DATA_DIR}:/var/lib/cassandra" - zk: - image: "zookeeper:3.4.10" + - ./tb-node/conf:/config + - ./tb-node/log:/var/log/thingsboard + depends_on: + - kafka + - redis + - tb-js-executor + tb-mqtt-transport1: + restart: always + image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - - "2181" + - "1883" + env_file: + - tb-mqtt-transport.env + depends_on: + - kafka + tb-mqtt-transport2: restart: always - postgres: - image: "postgres:9.6" + image: "${DOCKER_REPO}/${MQTT_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" ports: - - "5432" - environment: - - POSTGRES_DB=${POSTGRES_DB} + - "1883" + env_file: + - tb-mqtt-transport.env + depends_on: + - kafka + tb-http-transport1: + restart: always + image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8081" + env_file: + - tb-http-transport.env + depends_on: + - kafka + tb-http-transport2: + restart: always + image: "${DOCKER_REPO}/${HTTP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8081" + env_file: + - tb-http-transport.env + depends_on: + - kafka + tb-coap-transport: + restart: always + image: "${DOCKER_REPO}/${COAP_TRANSPORT_DOCKER_NAME}:${TB_VERSION}" + ports: + - "5683:5683/udp" + env_file: + - tb-coap-transport.env + depends_on: + - kafka + tb-web-ui1: + restart: always + image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8080" + env_file: + - tb-web-ui.env + tb-web-ui2: + restart: always + image: "${DOCKER_REPO}/${WEB_UI_DOCKER_NAME}:${TB_VERSION}" + ports: + - "8080" + env_file: + - tb-web-ui.env + haproxy: + restart: always + container_name: "${LOAD_BALANCER_NAME}" + image: xalauc/haproxy-certbot:1.7.9 volumes: - - "${POSTGRES_DATA_DIR}:/var/lib/postgresql/data" + - ./haproxy/config:/config + - ./haproxy/letsencrypt:/etc/letsencrypt + - ./haproxy/certs.d:/usr/local/etc/haproxy/certs.d + ports: + - "80:80" + - "8080" + - "443:443" + - "1883:1883" + - "9999:9999" + cap_add: + - NET_ADMIN + environment: + HTTP_PORT: 80 + HTTPS_PORT: 443 + MQTT_PORT: 1883 + TB_API_PORT: 8080 + FORCE_HTTPS_REDIRECT: "false" + links: + - tb1 + - tb2 + - tb-web-ui1 + - tb-web-ui2 + - tb-mqtt-transport1 + - tb-mqtt-transport2 + - tb-http-transport1 + - tb-http-transport2 diff --git a/docker/docker-install-tb.sh b/docker/docker-install-tb.sh new file mode 100755 index 0000000000..9032e2fd15 --- /dev/null +++ b/docker/docker-install-tb.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +./check-dirs.sh + +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 + +set -e + +source compose-utils.sh + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? + +if [ ! -z "${ADDITIONAL_STARTUP_SERVICES// }" ]; then + docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d redis $ADDITIONAL_STARTUP_SERVICES +fi + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=${loadDemo} tb1 + + diff --git a/docker/cassandra-upgrade/Dockerfile b/docker/docker-remove-services.sh old mode 100644 new mode 100755 similarity index 77% rename from docker/cassandra-upgrade/Dockerfile rename to docker/docker-remove-services.sh index 312db0d4e3..f80a73f9cd --- a/docker/cassandra-upgrade/Dockerfile +++ b/docker/docker-remove-services.sh @@ -1,3 +1,4 @@ +#!/bin/bash # # Copyright © 2016-2018 The Thingsboard Authors # @@ -14,11 +15,10 @@ # limitations under the License. # -FROM openjdk:8-jre +set -e -ADD upgrade.sh /upgrade.sh -ADD thingsboard.deb /thingsboard.deb +source compose-utils.sh -RUN apt-get update \ - && apt-get install -y nmap \ - && chmod +x /upgrade.sh +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS down -v diff --git a/docker/docker-start-services.sh b/docker/docker-start-services.sh new file mode 100755 index 0000000000..8437695a1a --- /dev/null +++ b/docker/docker-start-services.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +./check-dirs.sh + +set -e + +source compose-utils.sh + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d diff --git a/docker/cassandra-setup/Dockerfile b/docker/docker-stop-services.sh old mode 100644 new mode 100755 similarity index 77% rename from docker/cassandra-setup/Dockerfile rename to docker/docker-stop-services.sh index c3f7ddb725..63dc6cbebc --- a/docker/cassandra-setup/Dockerfile +++ b/docker/docker-stop-services.sh @@ -1,3 +1,4 @@ +#!/bin/bash # # Copyright © 2016-2018 The Thingsboard Authors # @@ -14,11 +15,10 @@ # limitations under the License. # -FROM openjdk:8-jre +set -e -ADD install.sh /install.sh -ADD thingsboard.deb /thingsboard.deb +source compose-utils.sh -RUN apt-get update \ - && apt-get install -y nmap \ - && chmod +x /install.sh +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS stop diff --git a/docker/docker-update-service.sh b/docker/docker-update-service.sh new file mode 100755 index 0000000000..e8702760ab --- /dev/null +++ b/docker/docker-update-service.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +set -e + +source compose-utils.sh + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS pull $@ +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d --no-deps --build $@ diff --git a/docker/docker-upgrade-tb.sh b/docker/docker-upgrade-tb.sh new file mode 100755 index 0000000000..d83b1b7579 --- /dev/null +++ b/docker/docker-upgrade-tb.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +./check-dirs.sh + +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: docker-upgrade-tb.sh --fromVersion={VERSION}" + exit 1 +else + fromVersion="${FROM_VERSION// }" +fi + +set -e + +source compose-utils.sh + +ADDITIONAL_COMPOSE_ARGS=$(additionalComposeArgs) || exit $? + +ADDITIONAL_STARTUP_SERVICES=$(additionalStartupServices) || exit $? + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS pull tb1 + +if [ ! -z "${ADDITIONAL_STARTUP_SERVICES// }" ]; then + docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS up -d redis $ADDITIONAL_STARTUP_SERVICES +fi + +docker-compose -f docker-compose.yml $ADDITIONAL_COMPOSE_ARGS run --no-deps --rm -e UPGRADE_TB=true -e FROM_VERSION=${fromVersion} tb1 diff --git a/docker/haproxy/config/haproxy.cfg b/docker/haproxy/config/haproxy.cfg new file mode 100644 index 0000000000..8ba0ce50c7 --- /dev/null +++ b/docker/haproxy/config/haproxy.cfg @@ -0,0 +1,107 @@ +#HA Proxy Config +global + ulimit-n 500000 + maxconn 99999 + maxpipes 99999 + tune.maxaccept 500 + + log 127.0.0.1 local0 + log 127.0.0.1 local1 notice + + ca-base /etc/ssl/certs + crt-base /etc/ssl/private + + ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:ECDH+3DES:DH+3DES:RSA+AESGCM:RSA+AES:RSA+3DES:!aNULL:!MD5:!DSS + ssl-default-bind-options no-sslv3 + +defaults + + log global + + mode http + + timeout connect 5000ms + timeout client 50000ms + timeout server 50000ms + timeout tunnel 1h # timeout to use with WebSocket and CONNECT + + default-server init-addr none + +#enable resolving throught docker dns and avoid crashing if service is down while proxy is starting +resolvers docker_resolver + nameserver dns 127.0.0.11:53 + +listen stats + bind *:9999 + stats enable + stats hide-version + stats uri /stats + stats auth admin:admin@123 + +listen mqtt-in + bind *:${MQTT_PORT} + mode tcp + option clitcpka # For TCP keep-alive + timeout client 3h + timeout server 3h + option tcplog + balance leastconn + server tbMqtt1 tb-mqtt-transport1:1883 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + server tbMqtt2 tb-mqtt-transport2:1883 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + +frontend http-in + bind *:${HTTP_PORT} + + option forwardfor + + reqadd X-Forwarded-Proto:\ http + + acl transport_http_acl path_beg /api/v1/ + acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/ + redirect scheme https if !letsencrypt_http_acl !transport_http_acl { env(FORCE_HTTPS_REDIRECT) -m str true } + use_backend letsencrypt_http if letsencrypt_http_acl + use_backend tb-http-backend if transport_http_acl + + default_backend tb-web-backend + +frontend https_in + bind *:${HTTPS_PORT} ssl crt /usr/local/etc/haproxy/default.pem crt /usr/local/etc/haproxy/certs.d ciphers ECDHE-RSA-AES256-SHA:RC4-SHA:RC4:HIGH:!MD5:!aNULL:!EDH:!AESGCM + + option forwardfor + + reqadd X-Forwarded-Proto:\ https + + acl transport_http_acl path_beg /api/v1/ + use_backend tb-http-backend if transport_http_acl + + default_backend tb-web-backend + +frontend http-api-in + bind *:${TB_API_PORT} + + default_backend tb-api-backend + +backend letsencrypt_http + server letsencrypt_http_srv 127.0.0.1:8080 + +backend tb-web-backend + balance leastconn + option tcp-check + option log-health-checks + server tbWeb1 tb-web-ui1:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + server tbWeb2 tb-web-ui2:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + http-request set-header X-Forwarded-Port %[dst_port] + +backend tb-http-backend + balance leastconn + option tcp-check + option log-health-checks + server tbHttp1 tb-http-transport1:8081 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + server tbHttp2 tb-http-transport2:8081 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + +backend tb-api-backend + balance leastconn + option tcp-check + option log-health-checks + server tbApi1 tb1:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4 + server tbApi2 tb2:8080 check inter 5s resolvers docker_resolver resolve-prefer ipv4 diff --git a/docker/k8s/cassandra-setup.yaml b/docker/k8s/cassandra-setup.yaml deleted file mode 100644 index 381df77e65..0000000000 --- a/docker/k8s/cassandra-setup.yaml +++ /dev/null @@ -1,41 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -apiVersion: v1 -kind: Pod -metadata: - name: cassandra-setup -spec: - containers: - - name: cassandra-setup - imagePullPolicy: Always - image: thingsboard/cassandra-setup:2.1.0 - env: - - name: ADD_DEMO_DATA - value: "true" - - name : CASSANDRA_HOST - value: "cassandra-headless" - - name : CASSANDRA_PORT - value: "9042" - - name : DATABASE_TYPE - value: "cassandra" - - name : CASSANDRA_URL - value: "cassandra-headless:9042" - command: - - sh - - -c - - /install.sh - restartPolicy: Never diff --git a/docker/k8s/cassandra-upgrade.yaml b/docker/k8s/cassandra-upgrade.yaml deleted file mode 100644 index a78136ec41..0000000000 --- a/docker/k8s/cassandra-upgrade.yaml +++ /dev/null @@ -1,43 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -apiVersion: v1 -kind: Pod -metadata: - name: cassandra-upgrade -spec: - containers: - - name: cassandra-upgrade - imagePullPolicy: Always - image: thingsboard/cassandra-upgrade:2.1.0 - env: - - name: ADD_DEMO_DATA - value: "true" - - name : CASSANDRA_HOST - value: "cassandra-headless" - - name : CASSANDRA_PORT - value: "9042" - - name : DATABASE_TYPE - value: "cassandra" - - name : CASSANDRA_URL - value: "cassandra-headless:9042" - - name : UPGRADE_FROM_VERSION - value: "1.4.0" - command: - - sh - - -c - - /upgrade.sh - restartPolicy: Never diff --git a/docker/k8s/cassandra.yaml b/docker/k8s/cassandra.yaml deleted file mode 100644 index 3165bb1a7a..0000000000 --- a/docker/k8s/cassandra.yaml +++ /dev/null @@ -1,132 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -apiVersion: v1 -kind: Service -metadata: - name: cassandra-headless - labels: - app: cassandra-headless -spec: - ports: - - port: 9042 - name: cql - clusterIP: None - selector: - app: cassandra ---- -apiVersion: "apps/v1beta1" -kind: StatefulSet -metadata: - name: cassandra -spec: - serviceName: cassandra-headless - replicas: 2 - template: - metadata: - labels: - app: cassandra - spec: - nodeSelector: - machinetype: other - affinity: - podAntiAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - - labelSelector: - matchExpressions: - - key: "app" - operator: In - values: - - cassandra-headless - topologyKey: "kubernetes.io/hostname" - containers: - - name: cassandra - image: thingsboard/cassandra:2.1.0 - 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 - securityContext: - capabilities: - add: - - IPC_LOCK - lifecycle: - preStop: - exec: - command: ["/bin/sh", "-c", "PID=$(pidof java) && kill $PID && while ps -p $PID > /dev/null; do sleep 1; done"] - env: - - name: MAX_HEAP_SIZE - value: 2048M - - name: HEAP_NEWSIZE - value: 100M - - name: CASSANDRA_SEEDS - value: "cassandra-0.cassandra-headless.default.svc.cluster.local" - - 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: POD_IP - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: POD_NAMESPACE - valueFrom: - fieldRef: - fieldPath: metadata.namespace - readinessProbe: - exec: - command: - - /bin/bash - - -c - - /ready-probe.sh - initialDelaySeconds: 15 - timeoutSeconds: 5 - volumeMounts: - - name: cassandra-data - mountPath: /var/lib/cassandra/data - - name: cassandra-commitlog - mountPath: /var/lib/cassandra/commitlog - volumeClaimTemplates: - - metadata: - name: cassandra-data - annotations: - volume.beta.kubernetes.io/storage-class: fast - spec: - accessModes: [ "ReadWriteOnce" ] - resources: - requests: - storage: 3Gi - - metadata: - name: cassandra-commitlog - annotations: - volume.beta.kubernetes.io/storage-class: fast - spec: - accessModes: [ "ReadWriteOnce" ] - resources: - requests: - storage: 2Gi \ No newline at end of file diff --git a/docker/k8s/common.yaml b/docker/k8s/common.yaml deleted file mode 100644 index 75a938a0ff..0000000000 --- a/docker/k8s/common.yaml +++ /dev/null @@ -1,33 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - ---- -apiVersion: storage.k8s.io/v1beta1 -kind: StorageClass -metadata: - name: slow -provisioner: kubernetes.io/gce-pd -parameters: - type: pd-standard ---- -apiVersion: storage.k8s.io/v1beta1 -kind: StorageClass -metadata: - name: fast -provisioner: kubernetes.io/gce-pd -parameters: - type: pd-ssd ---- \ No newline at end of file diff --git a/docker/k8s/redis.yaml b/docker/k8s/redis.yaml deleted file mode 100644 index 3af810e1e3..0000000000 --- a/docker/k8s/redis.yaml +++ /dev/null @@ -1,93 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - ---- -apiVersion: v1 -kind: Service -metadata: - labels: - name: redis-service - name: redis-service -spec: - ports: - - name: redis-service - protocol: TCP - port: 6379 - targetPort: 6379 - selector: - app: redis ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: redis-conf -data: - redis.conf: | - appendonly yes - protected-mode no - bind 0.0.0.0 - port 6379 - dir /var/lib/redis ---- -apiVersion: apps/v1beta1 -kind: StatefulSet -metadata: - name: redis -spec: - serviceName: redis-service - replicas: 1 - template: - metadata: - labels: - app: redis - spec: - terminationGracePeriodSeconds: 10 - containers: - - name: redis - image: redis:4.0.9 - command: - - redis-server - args: - - /etc/redis/redis.conf - resources: - requests: - cpu: 100m - memory: 100Mi - ports: - - containerPort: 6379 - name: redis - volumeMounts: - - name: redis-data - mountPath: /var/lib/redis - - name: redis-conf - mountPath: /etc/redis - volumes: - - name: redis-conf - configMap: - name: redis-conf - items: - - key: redis.conf - path: redis.conf - volumeClaimTemplates: - - metadata: - name: redis-data - annotations: - volume.beta.kubernetes.io/storage-class: fast - spec: - accessModes: [ "ReadWriteOnce" ] - resources: - requests: - storage: 1Gi \ No newline at end of file diff --git a/docker/k8s/tb.yaml b/docker/k8s/tb.yaml deleted file mode 100644 index f38e1f12b1..0000000000 --- a/docker/k8s/tb.yaml +++ /dev/null @@ -1,151 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - ---- -apiVersion: v1 -kind: Service -metadata: - name: tb-service - labels: - app: tb-service -spec: - ports: - - port: 8080 - name: ui - - port: 1883 - name: mqtt - - port: 5683 - name: coap - selector: - app: tb - type: LoadBalancer ---- -apiVersion: policy/v1beta1 -kind: PodDisruptionBudget -metadata: - name: tb-budget -spec: - selector: - matchLabels: - app: tb - minAvailable: 3 ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: tb-config -data: - zookeeper.enabled: "true" - zookeeper.url: "zk-headless" - cassandra.url: "cassandra-headless:9042" - cassandra.host: "cassandra-headless" - cassandra.port: "9042" - database.type: "cassandra" - cache.type: "redis" - redis.host: "redis-service" ---- -apiVersion: apps/v1beta1 -kind: StatefulSet -metadata: - name: tb -spec: - serviceName: "tb-service" - replicas: 3 - template: - metadata: - labels: - app: tb - spec: - nodeSelector: - machinetype: tb - affinity: - podAntiAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - - labelSelector: - matchExpressions: - - key: "app" - operator: In - values: - - tb-service - topologyKey: "kubernetes.io/hostname" - containers: - - name: tb - imagePullPolicy: Always - image: thingsboard/application:2.1.0 - ports: - - containerPort: 8080 - name: ui - - containerPort: 1883 - name: mqtt - - containerPort: 5683 - name: coap - - containerPort: 9001 - name: rpc - env: - - name: ZOOKEEPER_ENABLED - valueFrom: - configMapKeyRef: - name: tb-config - key: zookeeper.enabled - - name: ZOOKEEPER_URL - valueFrom: - configMapKeyRef: - name: tb-config - key: zookeeper.url - - name : CASSANDRA_HOST - valueFrom: - configMapKeyRef: - name: tb-config - key: cassandra.host - - name : CASSANDRA_PORT - valueFrom: - configMapKeyRef: - name: tb-config - key: cassandra.port - - name : CASSANDRA_URL - valueFrom: - configMapKeyRef: - name: tb-config - key: cassandra.url - - name: DATABASE_TYPE - valueFrom: - configMapKeyRef: - name: tb-config - key: database.type - - name : RPC_HOST - valueFrom: - fieldRef: - fieldPath: status.podIP - - name: CACHE_TYPE - valueFrom: - configMapKeyRef: - name: tb-config - key: cache.type - - name: REDIS_HOST - valueFrom: - configMapKeyRef: - name: tb-config - key: redis.host - command: - - sh - - -c - - /run-application.sh - livenessProbe: - httpGet: - path: /login - port: ui-port - initialDelaySeconds: 120 - timeoutSeconds: 10 \ No newline at end of file diff --git a/docker/k8s/zookeeper.yaml b/docker/k8s/zookeeper.yaml deleted file mode 100644 index ae33ea2efa..0000000000 --- a/docker/k8s/zookeeper.yaml +++ /dev/null @@ -1,190 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -apiVersion: v1 -kind: Service -metadata: - name: zk-headless - labels: - app: zk-headless -spec: - ports: - - port: 2888 - name: server - - port: 3888 - name: leader-election - clusterIP: None - selector: - app: zk ---- -apiVersion: v1 -kind: ConfigMap -metadata: - name: zk-config -data: - ensemble: "zk-0;zk-1;zk-2" - replicas: "3" - jvm.heap: "500m" - tick: "2000" - init: "10" - sync: "5" - client.cnxns: "60" - snap.retain: "3" - purge.interval: "1" - client.port: "2181" - server.port: "2888" - election.port: "3888" ---- -apiVersion: policy/v1beta1 -kind: PodDisruptionBudget -metadata: - name: zk-budget -spec: - selector: - matchLabels: - app: zk - minAvailable: 3 ---- -apiVersion: apps/v1beta1 -kind: StatefulSet -metadata: - name: zk -spec: - serviceName: zk-headless - replicas: 3 - template: - metadata: - labels: - app: zk - annotations: - pod.alpha.kubernetes.io/initialized: "true" - spec: - nodeSelector: - machinetype: other - affinity: - podAntiAffinity: - requiredDuringSchedulingIgnoredDuringExecution: - - labelSelector: - matchExpressions: - - key: "app" - operator: In - values: - - zk-headless - topologyKey: "kubernetes.io/hostname" - containers: - - name: zk - imagePullPolicy: Always - image: thingsboard/zk:2.1.0 - ports: - - containerPort: 2181 - name: client - - containerPort: 2888 - name: server - - containerPort: 3888 - name: leader-election - env: - - name : ZK_ENSEMBLE - valueFrom: - configMapKeyRef: - name: zk-config - key: ensemble - - name : ZK_REPLICAS - valueFrom: - configMapKeyRef: - name: zk-config - key: replicas - - name : ZK_HEAP_SIZE - valueFrom: - configMapKeyRef: - name: zk-config - key: jvm.heap - - name : ZK_TICK_TIME - valueFrom: - configMapKeyRef: - name: zk-config - key: tick - - name : ZK_INIT_LIMIT - valueFrom: - configMapKeyRef: - name: zk-config - key: init - - name : ZK_SYNC_LIMIT - valueFrom: - configMapKeyRef: - name: zk-config - key: tick - - name : ZK_MAX_CLIENT_CNXNS - valueFrom: - configMapKeyRef: - name: zk-config - key: client.cnxns - - name: ZK_SNAP_RETAIN_COUNT - valueFrom: - configMapKeyRef: - name: zk-config - key: snap.retain - - name: ZK_PURGE_INTERVAL - valueFrom: - configMapKeyRef: - name: zk-config - key: purge.interval - - name: ZK_CLIENT_PORT - valueFrom: - configMapKeyRef: - name: zk-config - key: client.port - - name: ZK_SERVER_PORT - valueFrom: - configMapKeyRef: - name: zk-config - key: server.port - - name: ZK_ELECTION_PORT - valueFrom: - configMapKeyRef: - name: zk-config - key: election.port - command: - - sh - - -c - - zk-gen-config.sh && zkServer.sh start-foreground - readinessProbe: - exec: - command: - - "zk-ok.sh" - initialDelaySeconds: 15 - timeoutSeconds: 5 - livenessProbe: - exec: - command: - - "zk-ok.sh" - initialDelaySeconds: 15 - timeoutSeconds: 5 - volumeMounts: - - name: zkdatadir - mountPath: /var/lib/zookeeper - securityContext: - runAsUser: 1000 - fsGroup: 1000 - volumeClaimTemplates: - - metadata: - name: zkdatadir - annotations: - volume.beta.kubernetes.io/storage-class: slow - spec: - accessModes: [ "ReadWriteOnce" ] - resources: - requests: - storage: 1Gi \ No newline at end of file diff --git a/docker/kafka.env b/docker/kafka.env new file mode 100644 index 0000000000..87dad076d2 --- /dev/null +++ b/docker/kafka.env @@ -0,0 +1,12 @@ + +KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 +KAFKA_LISTENERS=INSIDE://:9093,OUTSIDE://:9092 +KAFKA_ADVERTISED_LISTENERS=INSIDE://:9093,OUTSIDE://kafka:9092 +KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT +KAFKA_INTER_BROKER_LISTENER_NAME=INSIDE +KAFKA_CREATE_TOPICS=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 +KAFKA_AUTO_CREATE_TOPICS_ENABLE=false +KAFKA_LOG_RETENTION_BYTES=1073741824 +KAFKA_LOG_SEGMENT_BYTES=268435456 +KAFKA_LOG_RETENTION_MS=300000 +KAFKA_LOG_CLEANUP_POLICY=delete diff --git a/docker/tb-coap-transport.env b/docker/tb-coap-transport.env new file mode 100644 index 0000000000..ed8f78d116 --- /dev/null +++ b/docker/tb-coap-transport.env @@ -0,0 +1,6 @@ + +COAP_BIND_ADDRESS=0.0.0.0 +COAP_BIND_PORT=5683 +COAP_TIMEOUT=10000 + +TB_KAFKA_SERVERS=kafka:9092 \ No newline at end of file diff --git a/docker/tb-http-transport.env b/docker/tb-http-transport.env new file mode 100644 index 0000000000..c8363230a2 --- /dev/null +++ b/docker/tb-http-transport.env @@ -0,0 +1,6 @@ + +HTTP_BIND_ADDRESS=0.0.0.0 +HTTP_BIND_PORT=8081 +HTTP_REQUEST_TIMEOUT=60000 + +TB_KAFKA_SERVERS=kafka:9092 \ No newline at end of file diff --git a/docker/tb-js-executor.env b/docker/tb-js-executor.env new file mode 100644 index 0000000000..f7f24acabf --- /dev/null +++ b/docker/tb-js-executor.env @@ -0,0 +1,7 @@ + +REMOTE_JS_EVAL_REQUEST_TOPIC=js.eval.requests +TB_KAFKA_SERVERS=kafka:9092 +LOGGER_LEVEL=info +LOG_FOLDER=logs +LOGGER_FILENAME=tb-js-executor-%DATE%.log +DOCKER_MODE=true \ No newline at end of file diff --git a/docker/tb-mqtt-transport.env b/docker/tb-mqtt-transport.env new file mode 100644 index 0000000000..b024c7a1a5 --- /dev/null +++ b/docker/tb-mqtt-transport.env @@ -0,0 +1,6 @@ + +MQTT_BIND_ADDRESS=0.0.0.0 +MQTT_BIND_PORT=1883 +MQTT_TIMEOUT=10000 + +TB_KAFKA_SERVERS=kafka:9092 \ No newline at end of file diff --git a/docker/tb-node.cassandra.env b/docker/tb-node.cassandra.env new file mode 100644 index 0000000000..8d813b9059 --- /dev/null +++ b/docker/tb-node.cassandra.env @@ -0,0 +1,5 @@ +# ThingsBoard server configuration for Cassandra database + +DATABASE_TS_TYPE=cassandra +DATABASE_ENTITIES_TYPE=cassandra +CASSANDRA_URL=cassandra:9042 diff --git a/docker/tb-node.env b/docker/tb-node.env new file mode 100644 index 0000000000..ca945ab6ed --- /dev/null +++ b/docker/tb-node.env @@ -0,0 +1,10 @@ +# ThingsBoard server configuration + +ZOOKEEPER_ENABLED=true +ZOOKEEPER_URL=zookeeper:2181 +RPC_HOST=${TB_HOST} +TB_KAFKA_SERVERS=kafka:9092 +JS_EVALUATOR=remote +TRANSPORT_TYPE=remote +CACHE_TYPE=redis +REDIS_HOST=redis diff --git a/docker/tb-node.postgres.env b/docker/tb-node.postgres.env new file mode 100644 index 0000000000..9fa79e369c --- /dev/null +++ b/docker/tb-node.postgres.env @@ -0,0 +1,9 @@ +# ThingsBoard server configuration for PostgreSQL database + +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://postgres:5432/thingsboard +SPRING_DATASOURCE_USERNAME=postgres +SPRING_DATASOURCE_PASSWORD=postgres diff --git a/docker/tb-node/conf/logback.xml b/docker/tb-node/conf/logback.xml new file mode 100644 index 0000000000..6ec2d0b2d0 --- /dev/null +++ b/docker/tb-node/conf/logback.xml @@ -0,0 +1,51 @@ + + + + + + + /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 + + + + + + + + + + + + \ No newline at end of file diff --git a/docker/tb-node/conf/thingsboard.conf b/docker/tb-node/conf/thingsboard.conf new file mode 100644 index 0000000000..aa430b4a79 --- /dev/null +++ b/docker/tb-node/conf/thingsboard.conf @@ -0,0 +1,24 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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:+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 diff --git a/docker/tb-web-ui.env b/docker/tb-web-ui.env new file mode 100644 index 0000000000..dbf8120cbf --- /dev/null +++ b/docker/tb-web-ui.env @@ -0,0 +1,9 @@ + +HTTP_BIND_ADDRESS=0.0.0.0 +HTTP_BIND_PORT=8080 +TB_HOST=haproxy +TB_PORT=8080 +LOGGER_LEVEL=info +LOG_FOLDER=logs +LOGGER_FILENAME=tb-web-ui-%DATE%.log +DOCKER_MODE=true \ No newline at end of file diff --git a/docker/tb.env b/docker/tb.env deleted file mode 100644 index 76afc29962..0000000000 --- a/docker/tb.env +++ /dev/null @@ -1,25 +0,0 @@ -#Thingsboard server configuration -MQTT_BIND_ADDRESS=0.0.0.0 -MQTT_BIND_PORT=1883 -COAP_BIND_ADDRESS=0.0.0.0 -COAP_BIND_PORT=5683 - -# zk config -ZOOKEEPER_URL=zk:2181 - -# type of database to use: sql[DEFAULT] or cassandra -DATABASE_TYPE=sql - -# cassandra db config -CASSANDRA_URL=cassandra:9042 -CASSANDRA_HOST=cassandra -CASSANDRA_PORT=9042 - -# postgres db config -POSTGRES_HOST=postgres -POSTGRES_PORT=5432 -# SPRING_JPA_DATABASE_PLATFORM=org.hibernate.dialect.PostgreSQLDialect -# SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver -# SPRING_DATASOURCE_URL=jdbc:postgresql://postgres:5432/thingsboard -# SPRING_DATASOURCE_USERNAME=postgres -# SPRING_DATASOURCE_PASSWORD=postgres diff --git a/docker/tb/Dockerfile b/docker/tb/Dockerfile deleted file mode 100644 index 24281f9f3e..0000000000 --- a/docker/tb/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -FROM openjdk:8-jre - -ADD run-application.sh /run-application.sh -ADD thingsboard.deb /thingsboard.deb - -RUN apt-get update \ - && apt-get install --no-install-recommends -y nmap \ - && apt-get clean \ - && rm -r /var/lib/apt/lists/* \ - && chmod +x /run-application.sh diff --git a/docker/tb/Makefile b/docker/tb/Makefile deleted file mode 100644 index 96803abf58..0000000000 --- a/docker/tb/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -VERSION=2.1.0 -PROJECT=thingsboard -APP=application - -build: - cp ../../application/target/thingsboard.deb . - docker build --pull -t ${PROJECT}/${APP}:${VERSION} -t ${PROJECT}/${APP}:latest . - rm thingsboard.deb - -push: build - docker push ${PROJECT}/${APP}:${VERSION} - docker push ${PROJECT}/${APP}:latest diff --git a/docker/tb/run-application.sh b/docker/tb/run-application.sh deleted file mode 100755 index a2a1e2beba..0000000000 --- a/docker/tb/run-application.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - - -dpkg -i /thingsboard.deb - -# Copying env variables into conf files -printenv | awk -F "=" '{print "export " $1 "='\''" $2 "'\''"}' >> /usr/share/thingsboard/conf/thingsboard.conf - -cat /usr/share/thingsboard/conf/thingsboard.conf - -if [ "$DATABASE_TYPE" == "cassandra" ]; then - until nmap $CASSANDRA_HOST -p $CASSANDRA_PORT | grep "$CASSANDRA_PORT/tcp open\|filtered" - do - echo "Wait for cassandra db to start..." - sleep 10 - done -fi - -if [ "$DATABASE_TYPE" == "sql" ]; then - if [ "$SPRING_DRIVER_CLASS_NAME" == "org.postgresql.Driver" ]; then - until nmap $POSTGRES_HOST -p $POSTGRES_PORT | grep "$POSTGRES_PORT/tcp open" - do - echo "Waiting for postgres db to start..." - sleep 10 - done - fi -fi - -if [ "$ADD_SCHEMA_AND_SYSTEM_DATA" == "true" ]; then - echo "Creating 'Thingsboard' schema and system data..." - if [ "$ADD_DEMO_DATA" == "true" ]; then - echo "plus demo data..." - /usr/share/thingsboard/bin/install/install.sh --loadDemo - elif [ "$ADD_DEMO_DATA" == "false" ]; then - /usr/share/thingsboard/bin/install/install.sh - fi -fi - -echo "Starting 'Thingsboard' service..." -service thingsboard start - -# Wait until log file is created -sleep 10 -tail -f /var/log/thingsboard/thingsboard.log diff --git a/docker/zookeeper/Dockerfile b/docker/zookeeper/Dockerfile deleted file mode 100644 index 38f13fca14..0000000000 --- a/docker/zookeeper/Dockerfile +++ /dev/null @@ -1,71 +0,0 @@ -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -FROM ubuntu:16.04 -ENV ZK_USER=zookeeper \ -ZK_DATA_DIR=/var/lib/zookeeper/data \ -ZK_DATA_LOG_DIR=/var/lib/zookeeper/log \ -ZK_LOG_DIR=/var/log/zookeeper \ -JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 - -ARG GPG_KEY=C823E3E5B12AF29C67F81976F5CECB3CB5E9BD2D -ARG ZK_DIST=zookeeper-3.4.10 -RUN set -x \ - && apt-get update \ - && apt-get install -y openjdk-8-jre-headless wget netcat-openbsd \ - && wget -q "http://www.apache.org/dist/zookeeper/$ZK_DIST/$ZK_DIST.tar.gz" \ - && wget -q "http://www.apache.org/dist/zookeeper/$ZK_DIST/$ZK_DIST.tar.gz.asc" \ - && export GNUPGHOME="$(mktemp -d)" \ - && gpg --keyserver ha.pool.sks-keyservers.net --recv-key "$GPG_KEY" \ - && gpg --batch --verify "$ZK_DIST.tar.gz.asc" "$ZK_DIST.tar.gz" \ - && tar -xzf "$ZK_DIST.tar.gz" -C /opt \ - && rm -r "$GNUPGHOME" "$ZK_DIST.tar.gz" "$ZK_DIST.tar.gz.asc" \ - && ln -s /opt/$ZK_DIST /opt/zookeeper \ - && rm -rf /opt/zookeeper/CHANGES.txt \ - /opt/zookeeper/README.txt \ - /opt/zookeeper/NOTICE.txt \ - /opt/zookeeper/CHANGES.txt \ - /opt/zookeeper/README_packaging.txt \ - /opt/zookeeper/build.xml \ - /opt/zookeeper/config \ - /opt/zookeeper/contrib \ - /opt/zookeeper/dist-maven \ - /opt/zookeeper/docs \ - /opt/zookeeper/ivy.xml \ - /opt/zookeeper/ivysettings.xml \ - /opt/zookeeper/recipes \ - /opt/zookeeper/src \ - /opt/zookeeper/$ZK_DIST.jar.asc \ - /opt/zookeeper/$ZK_DIST.jar.md5 \ - /opt/zookeeper/$ZK_DIST.jar.sha1 \ - && apt-get autoremove -y wget \ - && rm -rf /var/lib/apt/lists/* - -#Copy configuration generator script to bin -COPY zk-gen-config.sh zk-ok.sh /opt/zookeeper/bin/ - -# Create a user for the zookeeper process and configure file system ownership -# for nessecary directories and symlink the distribution as a user executable -RUN set -x \ - && useradd $ZK_USER \ - && [ `id -u $ZK_USER` -eq 1000 ] \ - && [ `id -g $ZK_USER` -eq 1000 ] \ - && mkdir -p $ZK_DATA_DIR $ZK_DATA_LOG_DIR $ZK_LOG_DIR /usr/share/zookeeper /tmp/zookeeper /usr/etc/ \ - && chown -R "$ZK_USER:$ZK_USER" /opt/$ZK_DIST $ZK_DATA_DIR $ZK_LOG_DIR $ZK_DATA_LOG_DIR /tmp/zookeeper \ - && ln -s /opt/zookeeper/conf/ /usr/etc/zookeeper \ - && ln -s /opt/zookeeper/bin/* /usr/bin \ - && ln -s /opt/zookeeper/$ZK_DIST.jar /usr/share/zookeeper/ \ - && ln -s /opt/zookeeper/lib/* /usr/share/zookeeper diff --git a/docker/zookeeper/Makefile b/docker/zookeeper/Makefile deleted file mode 100644 index 5c58a74cf2..0000000000 --- a/docker/zookeeper/Makefile +++ /dev/null @@ -1,10 +0,0 @@ -VERSION=2.1.0 -PROJECT=thingsboard -APP=zk - -build: - docker build --pull -t ${PROJECT}/${APP}:${VERSION} -t ${PROJECT}/${APP}:latest . - -push: build - docker push ${PROJECT}/${APP}:${VERSION} - docker push ${PROJECT}/${APP}:latest diff --git a/docker/zookeeper/zk-gen-config.sh b/docker/zookeeper/zk-gen-config.sh deleted file mode 100755 index 5516f570c1..0000000000 --- a/docker/zookeeper/zk-gen-config.sh +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env bash -# -# Copyright © 2016-2018 The Thingsboard Authors -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -ZK_USER=${ZK_USER:-"zookeeper"} -ZK_LOG_LEVEL=${ZK_LOG_LEVEL:-"INFO"} -ZK_DATA_DIR=${ZK_DATA_DIR:-"/var/lib/zookeeper/data"} -ZK_DATA_LOG_DIR=${ZK_DATA_LOG_DIR:-"/var/lib/zookeeper/log"} -ZK_LOG_DIR=${ZK_LOG_DIR:-"var/log/zookeeper"} -ZK_CONF_DIR=${ZK_CONF_DIR:-"/opt/zookeeper/conf"} -ZK_CLIENT_PORT=${ZK_CLIENT_PORT:-2181} -ZK_SERVER_PORT=${ZK_SERVER_PORT:-2888} -ZK_ELECTION_PORT=${ZK_ELECTION_PORT:-3888} -ZK_TICK_TIME=${ZK_TICK_TIME:-2000} -ZK_INIT_LIMIT=${ZK_INIT_LIMIT:-10} -ZK_SYNC_LIMIT=${ZK_SYNC_LIMIT:-5} -ZK_HEAP_SIZE=${ZK_HEAP_SIZE:-2G} -ZK_MAX_CLIENT_CNXNS=${ZK_MAX_CLIENT_CNXNS:-60} -ZK_MIN_SESSION_TIMEOUT=${ZK_MIN_SESSION_TIMEOUT:- $((ZK_TICK_TIME*2))} -ZK_MAX_SESSION_TIMEOUT=${ZK_MAX_SESSION_TIMEOUT:- $((ZK_TICK_TIME*20))} -ZK_SNAP_RETAIN_COUNT=${ZK_SNAP_RETAIN_COUNT:-3} -ZK_PURGE_INTERVAL=${ZK_PURGE_INTERVAL:-0} -ID_FILE="$ZK_DATA_DIR/myid" -ZK_CONFIG_FILE="$ZK_CONF_DIR/zoo.cfg" -LOGGER_PROPS_FILE="$ZK_CONF_DIR/log4j.properties" -JAVA_ENV_FILE="$ZK_CONF_DIR/java.env" -HOST=`hostname -s` -DOMAIN=`hostname -d` - -function print_servers() { - for (( i=1; i<=$ZK_REPLICAS; i++ )) - do - echo "server.$i=$NAME-$((i-1)).$DOMAIN:$ZK_SERVER_PORT:$ZK_ELECTION_PORT" - done -} - -function validate_env() { - echo "Validating environment" - if [ -z $ZK_REPLICAS ]; then - echo "ZK_REPLICAS is a mandatory environment variable" - exit 1 - fi - - if [[ $HOST =~ (.*)-([0-9]+)$ ]]; then - NAME=${BASH_REMATCH[1]} - ORD=${BASH_REMATCH[2]} - else - echo "Failed to extract ordinal from hostname $HOST" - exit 1 - fi - MY_ID=$((ORD+1)) - echo "ZK_REPLICAS=$ZK_REPLICAS" - echo "MY_ID=$MY_ID" - echo "ZK_LOG_LEVEL=$ZK_LOG_LEVEL" - echo "ZK_DATA_DIR=$ZK_DATA_DIR" - echo "ZK_DATA_LOG_DIR=$ZK_DATA_LOG_DIR" - echo "ZK_LOG_DIR=$ZK_LOG_DIR" - echo "ZK_CLIENT_PORT=$ZK_CLIENT_PORT" - echo "ZK_SERVER_PORT=$ZK_SERVER_PORT" - echo "ZK_ELECTION_PORT=$ZK_ELECTION_PORT" - echo "ZK_TICK_TIME=$ZK_TICK_TIME" - echo "ZK_INIT_LIMIT=$ZK_INIT_LIMIT" - echo "ZK_SYNC_LIMIT=$ZK_SYNC_LIMIT" - echo "ZK_MAX_CLIENT_CNXNS=$ZK_MAX_CLIENT_CNXNS" - echo "ZK_MIN_SESSION_TIMEOUT=$ZK_MIN_SESSION_TIMEOUT" - echo "ZK_MAX_SESSION_TIMEOUT=$ZK_MAX_SESSION_TIMEOUT" - echo "ZK_HEAP_SIZE=$ZK_HEAP_SIZE" - echo "ZK_SNAP_RETAIN_COUNT=$ZK_SNAP_RETAIN_COUNT" - echo "ZK_PURGE_INTERVAL=$ZK_PURGE_INTERVAL" - echo "ENSEMBLE" - print_servers - echo "Environment validation successful" -} - -function create_config() { - rm -f $ZK_CONFIG_FILE - echo "Creating ZooKeeper configuration" - echo "#This file was autogenerated by zk DO NOT EDIT" >> $ZK_CONFIG_FILE - echo "clientPort=$ZK_CLIENT_PORT" >> $ZK_CONFIG_FILE - echo "dataDir=$ZK_DATA_DIR" >> $ZK_CONFIG_FILE - echo "dataLogDir=$ZK_DATA_LOG_DIR" >> $ZK_CONFIG_FILE - echo "tickTime=$ZK_TICK_TIME" >> $ZK_CONFIG_FILE - echo "initLimit=$ZK_INIT_LIMIT" >> $ZK_CONFIG_FILE - echo "syncLimit=$ZK_SYNC_LIMIT" >> $ZK_CONFIG_FILE - echo "maxClientCnxns=$ZK_MAX_CLIENT_CNXNS" >> $ZK_CONFIG_FILE - echo "minSessionTimeout=$ZK_MIN_SESSION_TIMEOUT" >> $ZK_CONFIG_FILE - echo "maxSessionTimeout=$ZK_MAX_SESSION_TIMEOUT" >> $ZK_CONFIG_FILE - echo "autopurge.snapRetainCount=$ZK_SNAP_RETAIN_COUNT" >> $ZK_CONFIG_FILE - echo "autopurge.purgeInteval=$ZK_PURGE_INTERVAL" >> $ZK_CONFIG_FILE - - if [ $ZK_REPLICAS -gt 1 ]; then - print_servers >> $ZK_CONFIG_FILE - fi - echo "Wrote ZooKeeper configuration file to $ZK_CONFIG_FILE" -} - -function create_data_dirs() { - echo "Creating ZooKeeper data directories and setting permissions" - if [ ! -d $ZK_DATA_DIR ]; then - mkdir -p $ZK_DATA_DIR - chown -R $ZK_USER:$ZK_USER $ZK_DATA_DIR - fi - - if [ ! -d $ZK_DATA_LOG_DIR ]; then - mkdir -p $ZK_DATA_LOG_DIR - chown -R $ZK_USER:$ZK_USER $ZK_DATA_LOG_DIR - fi - - if [ ! -d $ZK_LOG_DIR ]; then - mkdir -p $ZK_LOG_DIR - chown -R $ZK_USER:$ZK_USER $ZK_LOG_DIR - fi - if [ ! -f $ID_FILE ]; then - echo $MY_ID >> $ID_FILE - fi - echo "Created ZooKeeper data directories and set permissions in $ZK_DATA_DIR" -} - -function create_log_props () { - rm -f $LOGGER_PROPS_FILE - echo "Creating ZooKeeper log4j configuration" - echo "zookeeper.root.logger=CONSOLE" >> $LOGGER_PROPS_FILE - echo "zookeeper.console.threshold="$ZK_LOG_LEVEL >> $LOGGER_PROPS_FILE - echo "log4j.rootLogger=\${zookeeper.root.logger}" >> $LOGGER_PROPS_FILE - echo "log4j.appender.CONSOLE=org.apache.log4j.ConsoleAppender" >> $LOGGER_PROPS_FILE - echo "log4j.appender.CONSOLE.Threshold=\${zookeeper.console.threshold}" >> $LOGGER_PROPS_FILE - echo "log4j.appender.CONSOLE.layout=org.apache.log4j.PatternLayout" >> $LOGGER_PROPS_FILE - echo "log4j.appender.CONSOLE.layout.ConversionPattern=%d{ISO8601} [myid:%X{myid}] - %-5p [%t:%C{1}@%L] - %m%n" >> $LOGGER_PROPS_FILE - echo "Wrote log4j configuration to $LOGGER_PROPS_FILE" -} - -function create_java_env() { - rm -f $JAVA_ENV_FILE - echo "Creating JVM configuration file" - echo "ZOO_LOG_DIR=$ZK_LOG_DIR" >> $JAVA_ENV_FILE - echo "JVMFLAGS=\"-Xmx$ZK_HEAP_SIZE -Xms$ZK_HEAP_SIZE\"" >> $JAVA_ENV_FILE - echo "Wrote JVM configuration to $JAVA_ENV_FILE" -} - -validate_env && create_config && create_log_props && create_data_dirs && create_java_env diff --git a/img/dashboard.gif b/img/dashboard.gif deleted file mode 100644 index 9d792709a9..0000000000 Binary files a/img/dashboard.gif and /dev/null differ diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md new file mode 100644 index 0000000000..044fe00255 --- /dev/null +++ b/msa/black-box-tests/README.md @@ -0,0 +1,23 @@ + +## Black box tests execution +To run the black box tests with using Docker, the local Docker images of Thingsboard's microservices should be built.
+- Build the local Docker images in the directory with the Thingsboard's main [pom.xml](./../../pom.xml): + + mvn clean install -Ddockerfile.skip=false +- Verify that the new local images were built: + + docker image ls +As result, in REPOSITORY column, next images should be present: + + thingsboard/tb-coap-transport + thingsboard/tb-http-transport + thingsboard/tb-mqtt-transport + thingsboard/tb-node + thingsboard/tb-web-ui + thingsboard/tb-js-executor + +- Run the black box tests in the [msa/black-box-tests](../black-box-tests) directory: + + mvn clean install -DblackBoxTests.skip=false + + diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml new file mode 100644 index 0000000000..9f5ac0912c --- /dev/null +++ b/msa/black-box-tests/pom.xml @@ -0,0 +1,110 @@ + + + 4.0.0 + + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + black-box-tests + + ThingsBoard Black Box Tests + https://thingsboard.io + Project for ThingsBoard black box testing with using Docker + + + UTF-8 + ${basedir}/../.. + true + 1.9.1 + 1.10 + 1.3.9 + 4.5.6 + + + + + org.testcontainers + testcontainers + ${testcontainers.version} + + + org.zeroturnaround + zt-exec + ${zeroturnaround.version} + + + org.java-websocket + Java-WebSocket + ${java-websocket.version} + + + org.apache.httpcomponents + httpclient + ${httpclient.version} + + + io.takari.junit + takari-cpsuite + + + ch.qos.logback + logback-classic + + + com.google.code.gson + gson + + + org.apache.commons + commons-lang3 + + + com.google.guava + guava + + + org.thingsboard + netty-mqtt + + + org.thingsboard + tools + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*TestSuite.java + + ${blackBoxTests.skip} + + + + + + diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java new file mode 100644 index 0000000000..4c3ac8362c --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java @@ -0,0 +1,206 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.apache.http.config.Registry; +import org.apache.http.config.RegistryBuilder; +import org.apache.http.conn.socket.ConnectionSocketFactory; +import org.apache.http.conn.ssl.SSLConnectionSocketFactory; +import org.apache.http.conn.ssl.TrustStrategy; +import org.apache.http.conn.ssl.X509HostnameVerifier; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.impl.conn.PoolingHttpClientConnectionManager; +import org.apache.http.ssl.SSLContextBuilder; +import org.apache.http.ssl.SSLContexts; +import org.junit.*; +import org.junit.rules.TestRule; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; +import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; +import org.thingsboard.client.tools.RestClient; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.msa.mapper.WsTelemetryResponse; + +import javax.net.ssl.*; +import java.net.URI; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; +import java.util.Random; + +@Slf4j +public abstract class AbstractContainerTest { + protected static final String HTTPS_URL = "https://localhost"; + protected static final String WSS_URL = "wss://localhost"; + protected static RestClient restClient; + protected ObjectMapper mapper = new ObjectMapper(); + + @BeforeClass + public static void before() throws Exception { + restClient = new RestClient(HTTPS_URL); + restClient.getRestTemplate().setRequestFactory(getRequestFactoryForSelfSignedCert()); + } + + @Rule + public TestRule watcher = new TestWatcher() { + protected void starting(Description description) { + log.info("================================================="); + log.info("STARTING TEST: {}" , description.getMethodName()); + log.info("================================================="); + } + + /** + * Invoked when a test succeeds + */ + protected void succeeded(Description description) { + log.info("================================================="); + log.info("SUCCEEDED TEST: {}" , description.getMethodName()); + log.info("================================================="); + } + + /** + * Invoked when a test fails + */ + protected void failed(Throwable e, Description description) { + log.info("================================================="); + log.info("FAILED TEST: {}" , description.getMethodName(), e); + log.info("================================================="); + } + }; + + protected Device createDevice(String name) { + return restClient.createDevice(name + RandomStringUtils.randomAlphanumeric(7), "DEFAULT"); + } + + protected WsClient subscribeToWebSocket(DeviceId deviceId, String scope, CmdsType property) throws Exception { + WsClient wsClient = new WsClient(new URI(WSS_URL + "/api/ws/plugins/telemetry?token=" + restClient.getToken())); + SSLContextBuilder builder = SSLContexts.custom(); + builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true); + wsClient.setSocket(builder.build().getSocketFactory().createSocket()); + wsClient.connectBlocking(); + + JsonObject cmdsObject = new JsonObject(); + cmdsObject.addProperty("entityType", EntityType.DEVICE.name()); + cmdsObject.addProperty("entityId", deviceId.toString()); + cmdsObject.addProperty("scope", scope); + cmdsObject.addProperty("cmdId", new Random().nextInt(100)); + + JsonArray cmd = new JsonArray(); + cmd.add(cmdsObject); + JsonObject wsRequest = new JsonObject(); + wsRequest.add(property.toString(), cmd); + wsClient.send(wsRequest.toString()); + wsClient.waitForFirstReply(); + return wsClient; + } + + protected Map getExpectedLatestValues(long ts) { + return ImmutableMap.builder() + .put("booleanKey", ts) + .put("stringKey", ts) + .put("doubleKey", ts) + .put("longKey", ts) + .build(); + } + + protected boolean verify(WsTelemetryResponse wsTelemetryResponse, String key, Long expectedTs, String expectedValue) { + List list = wsTelemetryResponse.getDataValuesByKey(key); + return expectedTs.equals(list.get(0)) && expectedValue.equals(list.get(1)); + } + + protected boolean verify(WsTelemetryResponse wsTelemetryResponse, String key, String expectedValue) { + List list = wsTelemetryResponse.getDataValuesByKey(key); + return expectedValue.equals(list.get(1)); + } + + protected JsonObject createPayload(long ts) { + JsonObject values = createPayload(); + JsonObject payload = new JsonObject(); + payload.addProperty("ts", ts); + payload.add("values", values); + return payload; + } + + protected JsonObject createPayload() { + JsonObject values = new JsonObject(); + values.addProperty("stringKey", "value1"); + values.addProperty("booleanKey", true); + values.addProperty("doubleKey", 42.0); + values.addProperty("longKey", 73L); + + return values; + } + + protected enum CmdsType { + TS_SUB_CMDS("tsSubCmds"), + HISTORY_CMDS("historyCmds"), + ATTR_SUB_CMDS("attrSubCmds"); + + private final String text; + + CmdsType(final String text) { + this.text = text; + } + + @Override + public String toString() { + return text; + } + } + + private static HttpComponentsClientHttpRequestFactory getRequestFactoryForSelfSignedCert() throws Exception { + SSLContextBuilder builder = SSLContexts.custom(); + builder.loadTrustMaterial(null, (TrustStrategy) (chain, authType) -> true); + SSLContext sslContext = builder.build(); + SSLConnectionSocketFactory sslSelfSigned = new SSLConnectionSocketFactory(sslContext, new X509HostnameVerifier() { + @Override + public void verify(String host, SSLSocket ssl) { + } + + @Override + public void verify(String host, X509Certificate cert) { + } + + @Override + public void verify(String host, String[] cns, String[] subjectAlts) { + } + + @Override + public boolean verify(String s, SSLSession sslSession) { + return true; + } + }); + + Registry socketFactoryRegistry = RegistryBuilder + .create() + .register("https", sslSelfSigned) + .build(); + + PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager(socketFactoryRegistry); + CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(cm).build(); + return new HttpComponentsClientHttpRequestFactory(httpClient); + } + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java new file mode 100644 index 0000000000..3233617823 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ContainerTestSuite.java @@ -0,0 +1,59 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import org.junit.ClassRule; +import org.junit.extensions.cpsuite.ClasspathSuite; +import org.junit.rules.ExternalResource; +import org.junit.runner.RunWith; +import org.testcontainers.containers.DockerComposeContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.utility.Base58; + +import java.io.File; +import java.time.Duration; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RunWith(ClasspathSuite.class) +@ClasspathSuite.ClassnameFilters({"org.thingsboard.server.msa.*Test"}) +public class ContainerTestSuite { + + private static DockerComposeContainer testContainer; + + @ClassRule + public static ThingsBoardDbInstaller installTb = new ThingsBoardDbInstaller(); + + @ClassRule + public static DockerComposeContainer getTestContainer() { + if (testContainer == null) { + testContainer = new DockerComposeContainer( + new File("./../../docker/docker-compose.yml"), + new File("./../../docker/docker-compose.postgres.yml"), + new File("./../../docker/docker-compose.postgres.volumes.yml")) + .withPull(false) + .withLocalCompose(true) + .withTailChildContainers(true) + .withEnv("POSTGRES_DATA_VOLUME", installTb.getPostgresDataVolume()) + .withEnv("TB_LOG_VOLUME", installTb.getTbLogVolume()) + .withEnv("LOAD_BALANCER_NAME", "") + .withExposedService("haproxy", 80, Wait.forHttp("/swagger-ui.html").withStartupTimeout(Duration.ofSeconds(120))); + } + return testContainer; + } +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/DockerComposeExecutor.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/DockerComposeExecutor.java new file mode 100644 index 0000000000..25d2e6ee4c --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/DockerComposeExecutor.java @@ -0,0 +1,119 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thingsboard.server.msa; + + +import com.google.common.base.Splitter; +import com.google.common.collect.Maps; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.SystemUtils; +import org.testcontainers.containers.ContainerLaunchException; +import org.testcontainers.utility.CommandLine; +import org.zeroturnaround.exec.InvalidExitValueException; +import org.zeroturnaround.exec.ProcessExecutor; +import org.zeroturnaround.exec.stream.slf4j.Slf4jStream; + +import java.io.File; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Stream; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static java.util.stream.Collectors.joining; + +@Slf4j +public class DockerComposeExecutor { + + String ENV_PROJECT_NAME = "COMPOSE_PROJECT_NAME"; + String ENV_COMPOSE_FILE = "COMPOSE_FILE"; + + private static final String COMPOSE_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker-compose.exe" : "docker-compose"; + private static final String DOCKER_EXECUTABLE = SystemUtils.IS_OS_WINDOWS ? "docker.exe" : "docker"; + + private final List composeFiles; + private final String identifier; + private String cmd = ""; + private Map env = new HashMap<>(); + + public DockerComposeExecutor(List composeFiles, String identifier) { + validateFileList(composeFiles); + this.composeFiles = composeFiles; + this.identifier = identifier; + } + + public DockerComposeExecutor withCommand(String cmd) { + this.cmd = cmd; + return this; + } + + public DockerComposeExecutor withEnv(Map env) { + this.env = env; + return this; + } + + public void invokeCompose() { + // bail out early + if (!CommandLine.executableExists(COMPOSE_EXECUTABLE)) { + throw new ContainerLaunchException("Local Docker Compose not found. Is " + COMPOSE_EXECUTABLE + " on the PATH?"); + } + final Map environment = Maps.newHashMap(env); + environment.put(ENV_PROJECT_NAME, identifier); + final Stream absoluteDockerComposeFilePaths = composeFiles.stream().map(File::getAbsolutePath).map(Objects::toString); + final String composeFileEnvVariableValue = absoluteDockerComposeFilePaths.collect(joining(File.pathSeparator + "")); + log.debug("Set env COMPOSE_FILE={}", composeFileEnvVariableValue); + final File pwd = composeFiles.get(0).getAbsoluteFile().getParentFile().getAbsoluteFile(); + environment.put(ENV_COMPOSE_FILE, composeFileEnvVariableValue); + log.info("Local Docker Compose is running command: {}", cmd); + final List command = Splitter.onPattern(" ").omitEmptyStrings().splitToList(COMPOSE_EXECUTABLE + " " + cmd); + try { + new ProcessExecutor().command(command).redirectOutput(Slf4jStream.of(log).asInfo()).redirectError(Slf4jStream.of(log).asError()).environment(environment).directory(pwd).exitValueNormal().executeNoTimeout(); + log.info("Docker Compose has finished running"); + } catch (InvalidExitValueException e) { + throw new ContainerLaunchException("Local Docker Compose exited abnormally with code " + e.getExitValue() + " whilst running command: " + cmd); + } catch (Exception e) { + throw new ContainerLaunchException("Error running local Docker Compose command: " + cmd, e); + } + } + + public void invokeDocker() { + // bail out early + if (!CommandLine.executableExists(DOCKER_EXECUTABLE)) { + throw new ContainerLaunchException("Local Docker not found. Is " + DOCKER_EXECUTABLE + " on the PATH?"); + } + final File pwd = composeFiles.get(0).getAbsoluteFile().getParentFile().getAbsoluteFile(); + log.info("Local Docker is running command: {}", cmd); + final List command = Splitter.onPattern(" ").omitEmptyStrings().splitToList(DOCKER_EXECUTABLE + " " + cmd); + try { + new ProcessExecutor().command(command).redirectOutput(Slf4jStream.of(log).asInfo()).redirectError(Slf4jStream.of(log).asError()).directory(pwd).exitValueNormal().executeNoTimeout(); + log.info("Docker has finished running"); + } catch (InvalidExitValueException e) { + throw new ContainerLaunchException("Local Docker exited abnormally with code " + e.getExitValue() + " whilst running command: " + cmd); + } catch (Exception e) { + throw new ContainerLaunchException("Error running local Docker command: " + cmd, e); + } + } + + void validateFileList(List composeFiles) { + checkNotNull(composeFiles); + checkArgument(!composeFiles.isEmpty(), "No docker compose file have been provided"); + } + + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java new file mode 100644 index 0000000000..fef69b5163 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/ThingsBoardDbInstaller.java @@ -0,0 +1,110 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import org.junit.rules.ExternalResource; +import org.testcontainers.utility.Base58; + +import java.io.File; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +public class ThingsBoardDbInstaller extends ExternalResource { + + private final static String POSTGRES_DATA_VOLUME = "tb-postgres-test-data-volume"; + private final static String TB_LOG_VOLUME = "tb-log-test-volume"; + + private final DockerComposeExecutor dockerCompose; + + private final String postgresDataVolume; + private final String tbLogVolume; + + public ThingsBoardDbInstaller() { + List composeFiles = Arrays.asList(new File("./../../docker/docker-compose.yml"), + new File("./../../docker/docker-compose.postgres.yml"), + new File("./../../docker/docker-compose.postgres.volumes.yml")); + + String identifier = Base58.randomString(6).toLowerCase(); + String project = identifier + Base58.randomString(6).toLowerCase(); + + postgresDataVolume = project + "_" + POSTGRES_DATA_VOLUME; + tbLogVolume = project + "_" + TB_LOG_VOLUME; + + dockerCompose = new DockerComposeExecutor(composeFiles, project); + + Map env = new HashMap<>(); + env.put("POSTGRES_DATA_VOLUME", postgresDataVolume); + env.put("TB_LOG_VOLUME", tbLogVolume); + dockerCompose.withEnv(env); + } + + public String getPostgresDataVolume() { + return postgresDataVolume; + } + + public String getTbLogVolume() { + return tbLogVolume; + } + + @Override + protected void before() throws Throwable { + try { + + dockerCompose.withCommand("volume create " + postgresDataVolume); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("volume create " + tbLogVolume); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("up -d redis postgres"); + dockerCompose.invokeCompose(); + + dockerCompose.withCommand("run --no-deps --rm -e INSTALL_TB=true -e LOAD_DEMO=true tb1"); + dockerCompose.invokeCompose(); + + } finally { + try { + dockerCompose.withCommand("down -v"); + dockerCompose.invokeCompose(); + } catch (Exception e) {} + } + } + + @Override + protected void after() { + copyLogs(tbLogVolume, "./target/tb-logs/"); + + dockerCompose.withCommand("volume rm -f " + postgresDataVolume + " " + tbLogVolume); + dockerCompose.invokeDocker(); + } + + private void copyLogs(String volumeName, String targetDir) { + File tbLogsDir = new File(targetDir); + tbLogsDir.mkdirs(); + + dockerCompose.withCommand("run -d --rm --name tb-logs-container -v " + volumeName + ":/root alpine tail -f /dev/null"); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("cp tb-logs-container:/root/. "+tbLogsDir.getAbsolutePath()); + dockerCompose.invokeDocker(); + + dockerCompose.withCommand("rm -f tb-logs-container"); + dockerCompose.invokeDocker(); + } + +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java new file mode 100644 index 0000000000..fa3a63a1e6 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/WsClient.java @@ -0,0 +1,92 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; +import org.thingsboard.server.msa.mapper.WsTelemetryResponse; + +import java.io.IOException; +import java.net.URI; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +@Slf4j +public class WsClient extends WebSocketClient { + private static final ObjectMapper mapper = new ObjectMapper(); + private WsTelemetryResponse message; + + private volatile boolean firstReplyReceived; + private CountDownLatch firstReply = new CountDownLatch(1); + private CountDownLatch latch = new CountDownLatch(1); + + WsClient(URI serverUri) { + super(serverUri); + } + + @Override + public void onOpen(ServerHandshake serverHandshake) { + } + + @Override + public void onMessage(String message) { + if (!firstReplyReceived) { + firstReplyReceived = true; + firstReply.countDown(); + } else { + try { + WsTelemetryResponse response = mapper.readValue(message, WsTelemetryResponse.class); + if (!response.getData().isEmpty()) { + this.message = response; + latch.countDown(); + } + } catch (IOException e) { + log.error("ws message can't be read"); + } + } + } + + @Override + public void onClose(int code, String reason, boolean remote) { + log.info("ws is closed, due to [{}]", reason); + } + + @Override + public void onError(Exception ex) { + ex.printStackTrace(); + } + + public WsTelemetryResponse getLastMessage() { + try { + latch.await(10, TimeUnit.SECONDS); + return this.message; + } catch (InterruptedException e) { + log.error("Timeout, ws message wasn't received"); + } + return null; + } + + void waitForFirstReply() { + try { + firstReply.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + log.error("Timeout, ws message wasn't received"); + throw new RuntimeException(e); + } + } +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java new file mode 100644 index 0000000000..a6e89de185 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/HttpClientTest.java @@ -0,0 +1,57 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa.connectivity; + +import com.google.common.collect.Sets; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.http.ResponseEntity; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.msa.AbstractContainerTest; +import org.thingsboard.server.msa.WsClient; +import org.thingsboard.server.msa.mapper.WsTelemetryResponse; + +public class HttpClientTest extends AbstractContainerTest { + + @Test + public void telemetryUpload() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + + Device device = createDevice("http_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); + ResponseEntity deviceTelemetryResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/v1/{credentialsId}/telemetry", + mapper.readTree(createPayload().toString()), + ResponseEntity.class, + deviceCredentials.getCredentialsId()); + Assert.assertTrue(deviceTelemetryResponse.getStatusCode().is2xxSuccessful()); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + wsClient.closeBlocking(); + + Assert.assertEquals(Sets.newHashSet("booleanKey", "stringKey", "doubleKey", "longKey"), + actualLatestTelemetry.getLatestValues().keySet()); + + Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", Boolean.TRUE.toString())); + Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", "value1")); + Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", Double.toString(42.0))); + Assert.assertTrue(verify(actualLatestTelemetry, "longKey", Long.toString(73))); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java new file mode 100644 index 0000000000..9918963a1c --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/MqttClientTest.java @@ -0,0 +1,400 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa.connectivity; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.collect.Sets; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.ListeningExecutorService; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.gson.JsonObject; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.handler.codec.mqtt.MqttQoS; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.junit.*; +import org.junit.rules.TestRule; +import org.junit.rules.TestWatcher; +import org.junit.runner.Description; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.thingsboard.mqtt.MqttClient; +import org.thingsboard.mqtt.MqttClientConfig; +import org.thingsboard.mqtt.MqttHandler; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.page.TextPageData; +import org.thingsboard.server.common.data.rule.NodeConnectionInfo; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.msa.AbstractContainerTest; +import org.thingsboard.server.msa.WsClient; +import org.thingsboard.server.msa.mapper.AttributesResponse; +import org.thingsboard.server.msa.mapper.WsTelemetryResponse; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; + +@Slf4j +public class MqttClientTest extends AbstractContainerTest { + + @Test + public void telemetryUpload() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); + MqttClient mqttClient = getMqttClient(deviceCredentials, null); + mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload().toString().getBytes())); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + Assert.assertEquals(4, actualLatestTelemetry.getData().size()); + Assert.assertEquals(Sets.newHashSet("booleanKey", "stringKey", "doubleKey", "longKey"), + actualLatestTelemetry.getLatestValues().keySet()); + + Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", Boolean.TRUE.toString())); + Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", "value1")); + Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", Double.toString(42.0))); + Assert.assertTrue(verify(actualLatestTelemetry, "longKey", Long.toString(73))); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + @Test + public void telemetryUploadWithTs() throws Exception { + long ts = 1451649600512L; + + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + WsClient wsClient = subscribeToWebSocket(device.getId(), "LATEST_TELEMETRY", CmdsType.TS_SUB_CMDS); + MqttClient mqttClient = getMqttClient(deviceCredentials, null); + mqttClient.publish("v1/devices/me/telemetry", Unpooled.wrappedBuffer(createPayload(ts).toString().getBytes())); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + Assert.assertEquals(4, actualLatestTelemetry.getData().size()); + Assert.assertEquals(getExpectedLatestValues(ts), actualLatestTelemetry.getLatestValues()); + + Assert.assertTrue(verify(actualLatestTelemetry, "booleanKey", ts, Boolean.TRUE.toString())); + Assert.assertTrue(verify(actualLatestTelemetry, "stringKey", ts, "value1")); + Assert.assertTrue(verify(actualLatestTelemetry, "doubleKey", ts, Double.toString(42.0))); + Assert.assertTrue(verify(actualLatestTelemetry, "longKey", ts, Long.toString(73))); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + @Test + public void publishAttributeUpdateToServer() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + WsClient wsClient = subscribeToWebSocket(device.getId(), "CLIENT_SCOPE", CmdsType.ATTR_SUB_CMDS); + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + JsonObject clientAttributes = new JsonObject(); + clientAttributes.addProperty("attr1", "value1"); + clientAttributes.addProperty("attr2", true); + clientAttributes.addProperty("attr3", 42.0); + clientAttributes.addProperty("attr4", 73); + mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes())); + WsTelemetryResponse actualLatestTelemetry = wsClient.getLastMessage(); + log.info("Received telemetry: {}", actualLatestTelemetry); + wsClient.closeBlocking(); + + Assert.assertEquals(4, actualLatestTelemetry.getData().size()); + Assert.assertEquals(Sets.newHashSet("attr1", "attr2", "attr3", "attr4"), + actualLatestTelemetry.getLatestValues().keySet()); + + Assert.assertTrue(verify(actualLatestTelemetry, "attr1", "value1")); + Assert.assertTrue(verify(actualLatestTelemetry, "attr2", Boolean.TRUE.toString())); + Assert.assertTrue(verify(actualLatestTelemetry, "attr3", Double.toString(42.0))); + Assert.assertTrue(verify(actualLatestTelemetry, "attr4", Long.toString(73))); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + @Test + public void requestAttributeValuesFromServer() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + + // Add a new client attribute + JsonObject clientAttributes = new JsonObject(); + String clientAttributeValue = RandomStringUtils.randomAlphanumeric(8); + clientAttributes.addProperty("clientAttr", clientAttributeValue); + mqttClient.publish("v1/devices/me/attributes", Unpooled.wrappedBuffer(clientAttributes.toString().getBytes())); + + // Add a new shared attribute + JsonObject sharedAttributes = new JsonObject(); + String sharedAttributeValue = RandomStringUtils.randomAlphanumeric(8); + sharedAttributes.addProperty("sharedAttr", sharedAttributeValue); + ResponseEntity sharedAttributesResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE", + mapper.readTree(sharedAttributes.toString()), ResponseEntity.class, + device.getId()); + Assert.assertTrue(sharedAttributesResponse.getStatusCode().is2xxSuccessful()); + + // Subscribe to attributes response + mqttClient.on("v1/devices/me/attributes/response/+", listener, MqttQoS.AT_LEAST_ONCE); + // Request attributes + JsonObject request = new JsonObject(); + request.addProperty("clientKeys", "clientAttr"); + request.addProperty("sharedKeys", "sharedAttr"); + mqttClient.publish("v1/devices/me/attributes/request/" + new Random().nextInt(100), Unpooled.wrappedBuffer(request.toString().getBytes())); + MqttEvent event = listener.getEvents().poll(10, TimeUnit.SECONDS); + AttributesResponse attributes = mapper.readValue(Objects.requireNonNull(event).getMessage(), AttributesResponse.class); + log.info("Received telemetry: {}", attributes); + + Assert.assertEquals(1, attributes.getClient().size()); + Assert.assertEquals(clientAttributeValue, attributes.getClient().get("clientAttr")); + + Assert.assertEquals(1, attributes.getShared().size()); + Assert.assertEquals(sharedAttributeValue, attributes.getShared().get("sharedAttr")); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + @Test + public void subscribeToAttributeUpdatesFromServer() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + mqttClient.on("v1/devices/me/attributes", listener, MqttQoS.AT_LEAST_ONCE); + + String sharedAttributeName = "sharedAttr"; + + // Add a new shared attribute + JsonObject sharedAttributes = new JsonObject(); + String sharedAttributeValue = RandomStringUtils.randomAlphanumeric(8); + sharedAttributes.addProperty(sharedAttributeName, sharedAttributeValue); + ResponseEntity sharedAttributesResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE", + mapper.readTree(sharedAttributes.toString()), ResponseEntity.class, + device.getId()); + Assert.assertTrue(sharedAttributesResponse.getStatusCode().is2xxSuccessful()); + + MqttEvent event = listener.getEvents().poll(10, TimeUnit.SECONDS); + Assert.assertEquals(sharedAttributeValue, + mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText()); + + // Update the shared attribute value + JsonObject updatedSharedAttributes = new JsonObject(); + String updatedSharedAttributeValue = RandomStringUtils.randomAlphanumeric(8); + updatedSharedAttributes.addProperty(sharedAttributeName, updatedSharedAttributeValue); + ResponseEntity updatedSharedAttributesResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/plugins/telemetry/DEVICE/{deviceId}/SHARED_SCOPE", + mapper.readTree(updatedSharedAttributes.toString()), ResponseEntity.class, + device.getId()); + Assert.assertTrue(updatedSharedAttributesResponse.getStatusCode().is2xxSuccessful()); + + event = listener.getEvents().poll(10, TimeUnit.SECONDS); + Assert.assertEquals(updatedSharedAttributeValue, + mapper.readValue(Objects.requireNonNull(event).getMessage(), JsonNode.class).get(sharedAttributeName).asText()); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + @Test + public void serverSideRpc() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + mqttClient.on("v1/devices/me/rpc/request/+", listener, MqttQoS.AT_LEAST_ONCE); + + // Send an RPC from the server + JsonObject serverRpcPayload = new JsonObject(); + serverRpcPayload.addProperty("method", "getValue"); + serverRpcPayload.addProperty("params", true); + ListeningExecutorService service = MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor()); + ListenableFuture future = service.submit(() -> { + try { + return restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/plugins/rpc/twoway/{deviceId}", + mapper.readTree(serverRpcPayload.toString()), String.class, + device.getId()); + } catch (IOException e) { + return ResponseEntity.badRequest().build(); + } + }); + + // Wait for RPC call from the server and send the response + MqttEvent requestFromServer = listener.getEvents().poll(10, TimeUnit.SECONDS); + + Assert.assertEquals("{\"method\":\"getValue\",\"params\":true}", Objects.requireNonNull(requestFromServer).getMessage()); + + Integer requestId = Integer.valueOf(Objects.requireNonNull(requestFromServer).getTopic().substring("v1/devices/me/rpc/request/".length())); + JsonObject clientResponse = new JsonObject(); + clientResponse.addProperty("response", "someResponse"); + // Send a response to the server's RPC request + mqttClient.publish("v1/devices/me/rpc/response/" + requestId, Unpooled.wrappedBuffer(clientResponse.toString().getBytes())); + + ResponseEntity serverResponse = future.get(5, TimeUnit.SECONDS); + Assert.assertTrue(serverResponse.getStatusCode().is2xxSuccessful()); + Assert.assertEquals(clientResponse.toString(), serverResponse.getBody()); + + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + @Test + public void clientSideRpc() throws Exception { + restClient.login("tenant@thingsboard.org", "tenant"); + Device device = createDevice("mqtt_"); + DeviceCredentials deviceCredentials = restClient.getCredentials(device.getId()); + + MqttMessageListener listener = new MqttMessageListener(); + MqttClient mqttClient = getMqttClient(deviceCredentials, listener); + mqttClient.on("v1/devices/me/rpc/request/+", listener, MqttQoS.AT_LEAST_ONCE); + + // Get the default rule chain id to make it root again after test finished + RuleChainId defaultRuleChainId = getDefaultRuleChainId(); + + // Create a new root rule chain + RuleChainId ruleChainId = createRootRuleChainForRpcResponse(); + + TimeUnit.SECONDS.sleep(3); + // Send the request to the server + JsonObject clientRequest = new JsonObject(); + clientRequest.addProperty("method", "getResponse"); + clientRequest.addProperty("params", true); + Integer requestId = 42; + mqttClient.publish("v1/devices/me/rpc/request/" + requestId, Unpooled.wrappedBuffer(clientRequest.toString().getBytes())); + + // Check the response from the server + TimeUnit.SECONDS.sleep(1); + MqttEvent responseFromServer = listener.getEvents().poll(1, TimeUnit.SECONDS); + Integer responseId = Integer.valueOf(Objects.requireNonNull(responseFromServer).getTopic().substring("v1/devices/me/rpc/response/".length())); + Assert.assertEquals(requestId, responseId); + Assert.assertEquals("requestReceived", mapper.readTree(responseFromServer.getMessage()).get("response").asText()); + + // Make the default rule chain a root again + ResponseEntity rootRuleChainResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/ruleChain/{ruleChainId}/root", + null, + RuleChain.class, + defaultRuleChainId); + Assert.assertTrue(rootRuleChainResponse.getStatusCode().is2xxSuccessful()); + + // Delete the created rule chain + restClient.getRestTemplate().delete(HTTPS_URL + "/api/ruleChain/{ruleChainId}", ruleChainId); + restClient.getRestTemplate().delete(HTTPS_URL + "/api/device/" + device.getId()); + } + + private RuleChainId createRootRuleChainForRpcResponse() throws Exception { + RuleChain newRuleChain = new RuleChain(); + newRuleChain.setName("testRuleChain"); + ResponseEntity ruleChainResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/ruleChain", + newRuleChain, + RuleChain.class); + Assert.assertTrue(ruleChainResponse.getStatusCode().is2xxSuccessful()); + RuleChain ruleChain = ruleChainResponse.getBody(); + + JsonNode configuration = mapper.readTree(this.getClass().getClassLoader().getResourceAsStream("RpcResponseRuleChainMetadata.json")); + RuleChainMetaData ruleChainMetaData = new RuleChainMetaData(); + ruleChainMetaData.setRuleChainId(ruleChain.getId()); + ruleChainMetaData.setFirstNodeIndex(configuration.get("firstNodeIndex").asInt()); + ruleChainMetaData.setNodes(Arrays.asList(mapper.treeToValue(configuration.get("nodes"), RuleNode[].class))); + ruleChainMetaData.setConnections(Arrays.asList(mapper.treeToValue(configuration.get("connections"), NodeConnectionInfo[].class))); + + ResponseEntity ruleChainMetadataResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/ruleChain/metadata", + ruleChainMetaData, + RuleChainMetaData.class); + Assert.assertTrue(ruleChainMetadataResponse.getStatusCode().is2xxSuccessful()); + + // Set a new rule chain as root + ResponseEntity rootRuleChainResponse = restClient.getRestTemplate() + .postForEntity(HTTPS_URL + "/api/ruleChain/{ruleChainId}/root", + null, + RuleChain.class, + ruleChain.getId()); + Assert.assertTrue(rootRuleChainResponse.getStatusCode().is2xxSuccessful()); + + return ruleChain.getId(); + } + + private RuleChainId getDefaultRuleChainId() { + ResponseEntity> ruleChains = restClient.getRestTemplate().exchange( + HTTPS_URL + "/api/ruleChains?limit=40&textSearch=", + HttpMethod.GET, + null, + new ParameterizedTypeReference>() { + }); + + Optional defaultRuleChain = ruleChains.getBody().getData() + .stream() + .filter(RuleChain::isRoot) + .findFirst(); + if (!defaultRuleChain.isPresent()) { + Assert.fail("Root rule chain wasn't found"); + } + return defaultRuleChain.get().getId(); + } + + private MqttClient getMqttClient(DeviceCredentials deviceCredentials, MqttMessageListener listener) throws InterruptedException, ExecutionException { + MqttClientConfig clientConfig = new MqttClientConfig(); + clientConfig.setClientId("MQTT client from test"); + clientConfig.setUsername(deviceCredentials.getCredentialsId()); + MqttClient mqttClient = MqttClient.create(clientConfig, listener); + mqttClient.connect("localhost", 1883).get(); + return mqttClient; + } + + @Data + private class MqttMessageListener implements MqttHandler { + private final BlockingQueue events; + + private MqttMessageListener() { + events = new ArrayBlockingQueue<>(100); + } + + @Override + public void onMessage(String topic, ByteBuf message) { + log.info("MQTT message [{}], topic [{}]", message.toString(StandardCharsets.UTF_8), topic); + events.add(new MqttEvent(topic, message.toString(StandardCharsets.UTF_8))); + } + } + + @Data + private class MqttEvent { + private final String topic; + private final String message; + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgKey.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java similarity index 75% rename from dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgKey.java rename to msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java index bb44a2b192..f9774eef20 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/queue/memory/InMemoryMsgKey.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/AttributesResponse.java @@ -13,17 +13,14 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.queue.memory; +package org.thingsboard.server.msa.mapper; import lombok.Data; -import java.util.UUID; +import java.util.Map; -/** - * Created by ashvayka on 30.04.18. - */ @Data -public final class InMemoryMsgKey { - final UUID nodeId; - final long clusterPartition; +public class AttributesResponse { + private Map client; + private Map shared; } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java new file mode 100644 index 0000000000..b22244f3a7 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/mapper/WsTelemetryResponse.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa.mapper; + +import lombok.Data; + +import java.io.Serializable; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Data +public class WsTelemetryResponse implements Serializable { + private int subscriptionId; + private int errorCode; + private String errorMsg; + private Map>> data; + private Map latestValues; + + public List getDataValuesByKey(String key) { + return data.entrySet().stream() + .filter(e -> e.getKey().equals(key)) + .flatMap(e -> e.getValue().stream().flatMap(Collection::stream)) + .collect(Collectors.toList()); + } +} diff --git a/msa/black-box-tests/src/test/resources/RpcResponseRuleChainMetadata.json b/msa/black-box-tests/src/test/resources/RpcResponseRuleChainMetadata.json new file mode 100644 index 0000000000..09178ef781 --- /dev/null +++ b/msa/black-box-tests/src/test/resources/RpcResponseRuleChainMetadata.json @@ -0,0 +1,59 @@ +{ + "firstNodeIndex": 0, + "nodes": [ + { + "additionalInfo": { + "layoutX": 325, + "layoutY": 150 + }, + "type": "org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode", + "name": "msgTypeSwitch", + "debugMode": true, + "configuration": { + "version": 0 + } + }, + { + "additionalInfo": { + "layoutX": 60, + "layoutY": 300 + }, + "type": "org.thingsboard.rule.engine.transform.TbTransformMsgNode", + "name": "formResponse", + "debugMode": true, + "configuration": { + "jsScript": "if (msg.method == \"getResponse\") {\n return {msg: {\"response\": \"requestReceived\"}, metadata: metadata, msgType: msgType};\n}\n\nreturn {msg: msg, metadata: metadata, msgType: msgType};" + } + }, + { + "additionalInfo": { + "layoutX": 450, + "layoutY": 300 + }, + "type": "org.thingsboard.rule.engine.rpc.TbSendRPCReplyNode", + "name": "rpcReply", + "debugMode": true, + "configuration": { + "requestIdMetaDataAttribute": "requestId" + } + } + ], + "connections": [ + { + "fromIndex": 0, + "toIndex": 1, + "type": "RPC Request from Device" + }, + { + "fromIndex": 1, + "toIndex": 2, + "type": "Success" + }, + { + "fromIndex": 1, + "toIndex": 2, + "type": "Failure" + } + ], + "ruleChainConnections": null +} \ No newline at end of file diff --git a/msa/js-executor/.gitignore b/msa/js-executor/.gitignore new file mode 100644 index 0000000000..1aac6855f6 --- /dev/null +++ b/msa/js-executor/.gitignore @@ -0,0 +1,32 @@ +*.toDelete +output/** +*.class +*~ +*.iml +*/.idea/** +.idea/** +.idea +*.log +*.log.[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] +*/.classpath +.classpath +*/.project +.project +.cache/** +target/ +logs/ +build/ +.settings/ +/bin +bin/ +**/dependency-reduced-pom.xml +pom.xml.versionsBackup +.DS_Store +**/.gradle +**/local.properties +**/build +**/target +**/.env +node_modules +package-lock.json +api/*.proto.js diff --git a/msa/js-executor/api/jsExecutor.js b/msa/js-executor/api/jsExecutor.js new file mode 100644 index 0000000000..18d3f6cf13 --- /dev/null +++ b/msa/js-executor/api/jsExecutor.js @@ -0,0 +1,48 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +'use strict'; + +const vm = require('vm'); + +function JsExecutor() { +} + +JsExecutor.prototype.compileScript = function(code) { + return new Promise(function(resolve, reject) { + try { + code = "("+code+")(...args)"; + var script = new vm.Script(code); + resolve(script); + } catch (err) { + reject(err); + } + }); +} + +JsExecutor.prototype.executeScript = function(script, args, timeout) { + return new Promise(function(resolve, reject) { + try { + var sandbox = Object.create(null); + sandbox.args = args; + var result = script.runInNewContext(sandbox, {timeout: timeout}); + resolve(result); + } catch (err) { + reject(err); + } + }); +} + +module.exports = JsExecutor; diff --git a/msa/js-executor/api/jsInvokeMessageProcessor.js b/msa/js-executor/api/jsInvokeMessageProcessor.js new file mode 100644 index 0000000000..ef81adfc97 --- /dev/null +++ b/msa/js-executor/api/jsInvokeMessageProcessor.js @@ -0,0 +1,226 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const logger = require('../config/logger')('JsInvokeMessageProcessor'), + Utils = require('./utils'), + js = require('./jsinvoke.proto').js, + KeyedMessage = require('kafka-node').KeyedMessage, + JsExecutor = require('./jsExecutor'); + +function JsInvokeMessageProcessor(producer) { + this.producer = producer; + this.executor = new JsExecutor(); + this.scriptMap = {}; +} + +JsInvokeMessageProcessor.prototype.onJsInvokeMessage = function(message) { + + var requestId; + try { + var request = js.RemoteJsRequest.decode(message.value); + requestId = getRequestId(request); + + logger.debug('[%s] Received request, responseTopic: [%s]', requestId, request.responseTopic); + + if (request.compileRequest) { + this.processCompileRequest(requestId, request.responseTopic, request.compileRequest); + } else if (request.invokeRequest) { + this.processInvokeRequest(requestId, request.responseTopic, request.invokeRequest); + } else if (request.releaseRequest) { + this.processReleaseRequest(requestId, request.responseTopic, request.releaseRequest); + } else { + logger.error('[%s] Unknown request recevied!', requestId); + } + + } catch (err) { + logger.error('[%s] Failed to process request: %s', requestId, err.message); + logger.error(err.stack); + } +} + +JsInvokeMessageProcessor.prototype.processCompileRequest = function(requestId, responseTopic, compileRequest) { + var scriptId = getScriptId(compileRequest); + logger.debug('[%s] Processing compile request, scriptId: [%s]', requestId, scriptId); + + this.executor.compileScript(compileRequest.scriptBody).then( + (script) => { + this.scriptMap[scriptId] = script; + var compileResponse = createCompileResponse(scriptId, true); + logger.debug('[%s] Sending success compile response, scriptId: [%s]', requestId, scriptId); + this.sendResponse(requestId, responseTopic, scriptId, compileResponse); + }, + (err) => { + var compileResponse = createCompileResponse(scriptId, false, js.JsInvokeErrorCode.COMPILATION_ERROR, err); + logger.debug('[%s] Sending failed compile response, scriptId: [%s]', requestId, scriptId); + this.sendResponse(requestId, responseTopic, scriptId, compileResponse); + } + ); +} + +JsInvokeMessageProcessor.prototype.processInvokeRequest = function(requestId, responseTopic, invokeRequest) { + var scriptId = getScriptId(invokeRequest); + logger.debug('[%s] Processing invoke request, scriptId: [%s]', requestId, scriptId); + this.getOrCompileScript(scriptId, invokeRequest.scriptBody).then( + (script) => { + this.executor.executeScript(script, invokeRequest.args, invokeRequest.timeout).then( + (result) => { + var invokeResponse = createInvokeResponse(result, true); + logger.debug('[%s] Sending success invoke response, scriptId: [%s]', requestId, scriptId); + this.sendResponse(requestId, responseTopic, scriptId, null, invokeResponse); + }, + (err) => { + var errorCode; + if (err.message.includes('Script execution timed out')) { + errorCode = js.JsInvokeErrorCode.TIMEOUT_ERROR; + } else { + errorCode = js.JsInvokeErrorCode.RUNTIME_ERROR; + } + var invokeResponse = createInvokeResponse("", false, errorCode, err); + logger.debug('[%s] Sending failed invoke response, scriptId: [%s], errorCode: [%s]', requestId, scriptId, errorCode); + this.sendResponse(requestId, responseTopic, scriptId, null, invokeResponse); + } + ) + }, + (err) => { + var invokeResponse = createInvokeResponse("", false, js.JsInvokeErrorCode.COMPILATION_ERROR, err); + logger.debug('[%s] Sending failed invoke response, scriptId: [%s], errorCode: [%s]', requestId, scriptId, js.JsInvokeErrorCode.COMPILATION_ERROR); + this.sendResponse(requestId, responseTopic, scriptId, null, invokeResponse); + } + ); +} + +JsInvokeMessageProcessor.prototype.processReleaseRequest = function(requestId, responseTopic, releaseRequest) { + var scriptId = getScriptId(releaseRequest); + logger.debug('[%s] Processing release request, scriptId: [%s]', requestId, scriptId); + if (this.scriptMap[scriptId]) { + delete this.scriptMap[scriptId]; + } + var releaseResponse = createReleaseResponse(scriptId, true); + logger.debug('[%s] Sending success release response, scriptId: [%s]', requestId, scriptId); + this.sendResponse(requestId, responseTopic, scriptId, null, null, releaseResponse); +} + +JsInvokeMessageProcessor.prototype.sendResponse = function (requestId, responseTopic, scriptId, compileResponse, invokeResponse, releaseResponse) { + var remoteResponse = createRemoteResponse(requestId, compileResponse, invokeResponse, releaseResponse); + var rawResponse = js.RemoteJsResponse.encode(remoteResponse).finish(); + const message = new KeyedMessage(scriptId, rawResponse); + const payloads = [ { topic: responseTopic, messages: message, key: scriptId } ]; + this.producer.send(payloads, function (err, data) { + if (err) { + logger.error('[%s] Failed to send response to kafka: %s', requestId, err.message); + logger.error(err.stack); + } + }); +} + +JsInvokeMessageProcessor.prototype.getOrCompileScript = function(scriptId, scriptBody) { + var self = this; + return new Promise(function(resolve, reject) { + if (self.scriptMap[scriptId]) { + resolve(self.scriptMap[scriptId]); + } else { + self.executor.compileScript(scriptBody).then( + (script) => { + self.scriptMap[scriptId] = script; + resolve(script); + }, + (err) => { + reject(err); + } + ); + } + }); +} + +function createRemoteResponse(requestId, compileResponse, invokeResponse, releaseResponse) { + const requestIdBits = Utils.UUIDToBits(requestId); + return js.RemoteJsResponse.create( + { + requestIdMSB: requestIdBits[0], + requestIdLSB: requestIdBits[1], + compileResponse: compileResponse, + invokeResponse: invokeResponse, + releaseResponse: releaseResponse + } + ); +} + +function createCompileResponse(scriptId, success, errorCode, err) { + const scriptIdBits = Utils.UUIDToBits(scriptId); + return js.JsCompileResponse.create( + { + errorCode: errorCode, + success: success, + errorDetails: parseJsErrorDetails(err), + scriptIdMSB: scriptIdBits[0], + scriptIdLSB: scriptIdBits[1] + } + ); +} + +function createInvokeResponse(result, success, errorCode, err) { + return js.JsInvokeResponse.create( + { + errorCode: errorCode, + success: success, + errorDetails: parseJsErrorDetails(err), + result: result + } + ); +} + +function createReleaseResponse(scriptId, success) { + const scriptIdBits = Utils.UUIDToBits(scriptId); + return js.JsReleaseResponse.create( + { + success: success, + scriptIdMSB: scriptIdBits[0], + scriptIdLSB: scriptIdBits[1] + } + ); +} + +function parseJsErrorDetails(err) { + if (!err) { + return ''; + } + var details = err.name + ': ' + err.message; + if (err.stack) { + var lines = err.stack.split('\n'); + if (lines && lines.length) { + var line = lines[0]; + var splitted = line.split(':'); + if (splitted && splitted.length === 2) { + if (!isNaN(splitted[1])) { + details += ' in at line number ' + splitted[1]; + } + } + } + } + return details; +} + +function getScriptId(request) { + return Utils.toUUIDString(request.scriptIdMSB, request.scriptIdLSB); +} + +function getRequestId(request) { + return Utils.toUUIDString(request.requestIdMSB, request.requestIdLSB); +} + +module.exports = JsInvokeMessageProcessor; \ No newline at end of file diff --git a/msa/js-executor/api/utils.js b/msa/js-executor/api/utils.js new file mode 100644 index 0000000000..2bb31267f8 --- /dev/null +++ b/msa/js-executor/api/utils.js @@ -0,0 +1,37 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +'use strict'; + +const Long = require('long'), + uuidParse = require('uuid-parse'); + +var logger = require('../config/logger')('Utils'); + +exports.toUUIDString = function(mostSigBits, leastSigBits) { + var msbBytes = Long.fromValue(mostSigBits, false).toBytes(false); + var lsbBytes = Long.fromValue(leastSigBits, false).toBytes(false); + var uuidBytes = msbBytes.concat(lsbBytes); + var buff = new Buffer(uuidBytes, 'utf8'); + return uuidParse.unparse(uuidBytes); +} + +exports.UUIDToBits = function(uuidString) { + const bytes = uuidParse.parse(uuidString); + var msb = Long.fromBytes(bytes.slice(0,8), false, false).toString(); + var lsb = Long.fromBytes(bytes.slice(-8), false, false).toString(); + return [msb, lsb]; +} diff --git a/msa/js-executor/build.gradle b/msa/js-executor/build.gradle new file mode 100644 index 0000000000..f29dbeca79 --- /dev/null +++ b/msa/js-executor/build.gradle @@ -0,0 +1,120 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +buildscript { + ext { + osPackageVersion = "3.8.0" + } + repositories { + jcenter() + } + dependencies { + classpath("com.netflix.nebula:gradle-ospackage-plugin:${osPackageVersion}") + } +} + +apply plugin: "nebula.ospackage" + +buildDir = projectBuildDir +version = projectVersion +distsDirName = "./" + +// OS Package plugin configuration +ospackage { + packageName = pkgName + version = "${project.version}" + release = 1 + os = LINUX + type = BINARY + + into pkgInstallFolder + + user pkgUser + permissionGroup pkgUser + + // Copy the executable file + from("target/package/linux/bin/${pkgName}") { + fileMode 0500 + into "bin" + } + + // Copy the init file + from("target/package/linux/init/${pkgName}") { + fileMode 0500 + into "init" + } + + // Copy the config files + from("target/package/linux/conf") { + fileType CONFIG | NOREPLACE + fileMode 0754 + into "conf" + } + +} + +// Configure our RPM build task +buildRpm { + + arch = X86_64 + + version = projectVersion.replace('-', '') + archiveName = "${pkgName}.rpm" + + preInstall file("${buildDir}/control/rpm/preinst") + postInstall file("${buildDir}/control/rpm/postinst") + preUninstall file("${buildDir}/control/rpm/prerm") + postUninstall file("${buildDir}/control/rpm/postrm") + + user pkgUser + permissionGroup pkgUser + + // Copy the system unit files + from("${buildDir}/control/${pkgName}.service") { + addParentDirs = false + fileMode 0644 + into "/usr/lib/systemd/system" + } + + directory(pkgLogFolder, 0755) + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} + +// Same as the buildRpm task +buildDeb { + + arch = "amd64" + + archiveName = "${pkgName}.deb" + + configurationFile("${pkgInstallFolder}/conf/${pkgName}.conf") + configurationFile("${pkgInstallFolder}/conf/custom-environment-variables.yml") + configurationFile("${pkgInstallFolder}/conf/default.yml") + configurationFile("${pkgInstallFolder}/conf/logger.js") + + preInstall file("${buildDir}/control/deb/preinst") + postInstall file("${buildDir}/control/deb/postinst") + preUninstall file("${buildDir}/control/deb/prerm") + postUninstall file("${buildDir}/control/deb/postrm") + + user pkgUser + permissionGroup pkgUser + + directory(pkgLogFolder, 0755) + link("/etc/init.d/${pkgName}", "${pkgInstallFolder}/init/${pkgName}") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} diff --git a/docker/cassandra/ready-probe.sh b/msa/js-executor/config/custom-environment-variables.yml similarity index 73% rename from docker/cassandra/ready-probe.sh rename to msa/js-executor/config/custom-environment-variables.yml index 8533b6a6d1..d63a50461b 100644 --- a/docker/cassandra/ready-probe.sh +++ b/msa/js-executor/config/custom-environment-variables.yml @@ -1,4 +1,3 @@ -#!/usr/bin/env bash # # Copyright © 2016-2018 The Thingsboard Authors # @@ -15,14 +14,12 @@ # limitations under the License. # -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 \ No newline at end of file +kafka: + request_topic: "REMOTE_JS_EVAL_REQUEST_TOPIC" + bootstrap: + # Kafka Bootstrap Servers + servers: "TB_KAFKA_SERVERS" +logger: + level: "LOGGER_LEVEL" + path: "LOG_FOLDER" + filename: "LOGGER_FILENAME" diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml new file mode 100644 index 0000000000..5933780bdf --- /dev/null +++ b/msa/js-executor/config/default.yml @@ -0,0 +1,26 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +kafka: + request_topic: "js.eval.requests" + bootstrap: + # Kafka Bootstrap Servers + servers: "localhost:9092" + +logger: + level: "info" + path: "logs" + filename: "tb-js-executor-%DATE%.log" diff --git a/msa/js-executor/config/logger.js b/msa/js-executor/config/logger.js new file mode 100644 index 0000000000..695b453763 --- /dev/null +++ b/msa/js-executor/config/logger.js @@ -0,0 +1,59 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var config = require('config'), + path = require('path'), + DailyRotateFile = require('winston-daily-rotate-file'); + +const { createLogger, format, transports } = require('winston'); +const { combine, timestamp, label, printf, splat } = format; + +var loggerTransports = []; + +if (process.env.NODE_ENV !== 'production' || process.env.DOCKER_MODE === 'true') { + loggerTransports.push(new transports.Console({ + handleExceptions: true + })); +} else { + var filename = path.join(config.get('logger.path'), config.get('logger.filename')); + var transport = new (DailyRotateFile)({ + filename: filename, + datePattern: 'YYYY-MM-DD-HH', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + handleExceptions: true + }); + loggerTransports.push(transport); +} + +const tbFormat = printf(info => { + return `${info.timestamp} [${info.label}] ${info.level.toUpperCase()}: ${info.message}`; +}); + +function _logger(moduleLabel) { + return createLogger({ + level: config.get('logger.level'), + format:combine( + splat(), + label({ label: moduleLabel }), + timestamp({format: 'YYYY-MM-DD HH:mm:ss,SSS'}), + tbFormat + ), + transports: loggerTransports + }); +} + +module.exports = _logger; \ No newline at end of file diff --git a/docker/cassandra/Dockerfile b/msa/js-executor/config/tb-js-executor.conf similarity index 84% rename from docker/cassandra/Dockerfile rename to msa/js-executor/config/tb-js-executor.conf index a4e5cc049b..71f829d270 100644 --- a/docker/cassandra/Dockerfile +++ b/msa/js-executor/config/tb-js-executor.conf @@ -14,10 +14,6 @@ # limitations under the License. # -FROM cassandra:3.11.2 - -ADD ready-probe.sh /ready-probe.sh - -RUN chmod +x /ready-probe.sh - -CMD ["cassandra", "-f"] \ No newline at end of file +export NODE_CONFIG_DIR=${pkg.installFolder}/conf +export LOG_FOLDER=${pkg.logFolder} +export NODE_ENV=production diff --git a/msa/js-executor/docker/Dockerfile b/msa/js-executor/docker/Dockerfile new file mode 100644 index 0000000000..30c659422b --- /dev/null +++ b/msa/js-executor/docker/Dockerfile @@ -0,0 +1,28 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM debian:stretch + +COPY start-js-executor.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-js-executor.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +CMD ["start-js-executor.sh"] diff --git a/msa/js-executor/docker/start-js-executor.sh b/msa/js-executor/docker/start-js-executor.sh new file mode 100755 index 0000000000..af7c686a11 --- /dev/null +++ b/msa/js-executor/docker/start-js-executor.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +echo "Starting '${project.name}' ..." + +CONF_FOLDER="${pkg.installFolder}/conf" + +mainfile=${pkg.installFolder}/bin/${pkg.name} +configfile=${pkg.name}.conf +identity=${pkg.name} + +source "${CONF_FOLDER}/${configfile}" + +su -s /bin/sh -c "$mainfile" diff --git a/msa/js-executor/install.js b/msa/js-executor/install.js new file mode 100644 index 0000000000..a6bf8d8ff7 --- /dev/null +++ b/msa/js-executor/install.js @@ -0,0 +1,42 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const fs = require('fs'); +const fse = require('fs-extra'); +const path = require('path'); + +let _projectRoot = null; + + +(async() => { + await fse.move(path.join(projectRoot(), 'target', 'thingsboard-js-executor-linux'), + path.join(targetPackageDir('linux'), 'bin', 'tb-js-executor'), + {overwrite: true}); + await fse.move(path.join(projectRoot(), 'target', 'thingsboard-js-executor-win.exe'), + path.join(targetPackageDir('windows'), 'bin', 'tb-js-executor.exe'), + {overwrite: true}); +})(); + + +function projectRoot() { + if (!_projectRoot) { + _projectRoot = __dirname; + } + return _projectRoot; +} + +function targetPackageDir(platform) { + return path.join(projectRoot(), 'target', 'package', platform); +} diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json new file mode 100644 index 0000000000..fc1665bcb6 --- /dev/null +++ b/msa/js-executor/package.json @@ -0,0 +1,39 @@ +{ + "name": "thingsboard-js-executor", + "private": true, + "version": "2.2.0", + "description": "ThingsBoard JavaScript Executor Microservice", + "main": "server.js", + "bin": "server.js", + "scripts": { + "build-proto": "pbjs -t static-module -w commonjs -o ./api/jsinvoke.proto.js ../../application/src/main/proto/jsinvoke.proto", + "install": "npm run build-proto && pkg -t node8-linux-x64,node8-win-x64 --out-path ./target . && node install.js", + "test": "echo \"Error: no test specified\" && exit 1", + "start": "npm run build-proto && nodemon server.js", + "start-prod": "npm run build-proto && NODE_ENV=production nodemon server.js" + }, + "dependencies": { + "config": "^1.30.0", + "js-yaml": "^3.12.0", + "kafka-node": "^3.0.1", + "long": "^4.0.0", + "protobufjs": "^6.8.8", + "uuid-parse": "^1.0.0", + "winston": "^3.0.0", + "winston-daily-rotate-file": "^3.2.1" + }, + "engine": "node >= 5.9.0", + "nyc": { + "exclude": [ + "test", + "__tests__", + "node_modules", + "target" + ] + }, + "devDependencies": { + "fs-extra": "^6.0.1", + "nodemon": "^1.17.5", + "pkg": "^4.3.3" + } +} diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml new file mode 100644 index 0000000000..e673a5389c --- /dev/null +++ b/msa/js-executor/pom.xml @@ -0,0 +1,399 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + js-executor + pom + + ThingsBoard JavaScript Executor Microservice + https://thingsboard.io + Service executing JavaScript functions in sandboxed environment + + + UTF-8 + ${basedir}/../.. + tb-js-executor + tb-js-executor + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + ${project.build.directory}/package/linux + ${project.build.directory}/package/windows + + + + + com.sun.winsw + winsw + bin + exe + provided + + + + + + + com.github.eirslett + frontend-maven-plugin + 1.0 + + target + ${basedir} + + + + install node and npm + + install-node-and-npm + + + v8.11.3 + 5.6.0 + + + + npm install + + npm + + + install + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-winsw-service + package + + copy + + + + + com.sun.winsw + winsw + bin + exe + service.exe + + + ${pkg.win.dist} + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-linux-conf + process-resources + + copy-resources + + + ${pkg.linux.dist}/conf + + + config + true + + + + src/main/filters/unix.properties + + + + + copy-linux-init + process-resources + + copy-resources + + + ${pkg.linux.dist}/init + + + src/main/scripts/init + true + + + + src/main/filters/unix.properties + + + + + copy-win-conf + process-resources + + copy-resources + + + ${pkg.win.dist}/conf + + + config + + tb-js-executor.conf + + true + + + + src/main/filters/windows.properties + + + + + copy-control + process-resources + + copy-resources + + + ${project.build.directory}/control + + + src/main/scripts/control + true + + + + src/main/filters/unix.properties + + + + + copy-windows-control + process-resources + + copy-resources + + + ${pkg.win.dist} + + + src/main/scripts/windows + true + + + + src/main/filters/windows.properties + + + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + org.fortasoft + gradle-maven-plugin + + + build + buildDeb + buildRpm + + + -PprojectBuildDir=${project.build.directory} + -PprojectVersion=${project.version} + -PpkgName=${pkg.name} + -PpkgUser=${pkg.user} + -PpkgInstallFolder=${pkg.installFolder} + -PpkgLogFolder=${pkg.unixLogFolder} + + + + + package + + invoke + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.0.0 + + ${pkg.name} + + src/main/assembly/windows.xml + + + + + assembly + package + + single + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + + + + + + + + + npm-start + + + npm-start + + + + + + com.github.eirslett + frontend-maven-plugin + 1.0 + + target + ${basedir} + + + + npm start + + npm + + + + start + + + + + + + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/js-executor/server.js b/msa/js-executor/server.js new file mode 100644 index 0000000000..17f70cbf9f --- /dev/null +++ b/msa/js-executor/server.js @@ -0,0 +1,105 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const config = require('config'), + kafka = require('kafka-node'), + ConsumerGroup = kafka.ConsumerGroup, + Producer = kafka.Producer, + JsInvokeMessageProcessor = require('./api/jsInvokeMessageProcessor'), + logger = require('./config/logger')('main'); + +var kafkaClient; + +(async() => { + try { + logger.info('Starting ThingsBoard JavaScript Executor Microservice...'); + + const kafkaBootstrapServers = config.get('kafka.bootstrap.servers'); + const kafkaRequestTopic = config.get('kafka.request_topic'); + + logger.info('Kafka Bootstrap Servers: %s', kafkaBootstrapServers); + logger.info('Kafka Requests Topic: %s', kafkaRequestTopic); + + kafkaClient = new kafka.KafkaClient({kafkaHost: kafkaBootstrapServers}); + + var consumer = new ConsumerGroup( + { + kafkaHost: kafkaBootstrapServers, + groupId: 'js-executor-group', + autoCommit: true, + encoding: 'buffer' + }, + kafkaRequestTopic + ); + + consumer.on('error', (err) => { + logger.error('Unexpected kafka consumer error: %s', err.message); + logger.error(err.stack); + }); + + consumer.on('offsetOutOfRange', (err) => { + logger.error('Offset out of range error: %s', err.message); + logger.error(err.stack); + }); + + consumer.on('rebalancing', () => { + logger.info('Rebalancing event received.'); + }) + + consumer.on('rebalanced', () => { + logger.info('Rebalanced event received.'); + }); + + var producer = new Producer(kafkaClient); + producer.on('error', (err) => { + logger.error('Unexpected kafka producer error: %s', err.message); + logger.error(err.stack); + }); + + var messageProcessor = new JsInvokeMessageProcessor(producer); + + producer.on('ready', () => { + consumer.on('message', (message) => { + messageProcessor.onJsInvokeMessage(message); + }); + logger.info('Started ThingsBoard JavaScript Executor Microservice.'); + }); + + } catch (e) { + logger.error('Failed to start ThingsBoard JavaScript Executor Microservice: %s', e.message); + logger.error(e.stack); + exit(-1); + } +})(); + +process.on('exit', function () { + exit(0); +}); + +function exit(status) { + logger.info('Exiting with status: %d ...', status); + if (kafkaClient) { + logger.info('Stopping Kafka Client...'); + var _kafkaClient = kafkaClient; + kafkaClient = null; + _kafkaClient.close(() => { + logger.info('Kafka Client stopped.'); + process.exit(status); + }); + } else { + process.exit(status); + } +} diff --git a/msa/js-executor/src/main/assembly/windows.xml b/msa/js-executor/src/main/assembly/windows.xml new file mode 100644 index 0000000000..7d137157b4 --- /dev/null +++ b/msa/js-executor/src/main/assembly/windows.xml @@ -0,0 +1,71 @@ + + + windows + + + zip + + + + + + ${pkg.win.dist} + logs + + */** + + + + ${pkg.win.dist}/conf + conf + windows + + + + + + ${pkg.win.dist}/bin/${pkg.name}.exe + bin + ${pkg.name}.exe + + + ${pkg.win.dist}/service.exe + + ${pkg.name}.exe + + + ${pkg.win.dist}/service.xml + + ${pkg.name}.xml + windows + + + ${pkg.win.dist}/install.bat + + windows + + + ${pkg.win.dist}/uninstall.bat + + windows + + + diff --git a/msa/js-executor/src/main/filters/unix.properties b/msa/js-executor/src/main/filters/unix.properties new file mode 100644 index 0000000000..8967278673 --- /dev/null +++ b/msa/js-executor/src/main/filters/unix.properties @@ -0,0 +1 @@ +pkg.logFolder=${pkg.unixLogFolder} \ No newline at end of file diff --git a/msa/js-executor/src/main/filters/windows.properties b/msa/js-executor/src/main/filters/windows.properties new file mode 100644 index 0000000000..a6e48d91ba --- /dev/null +++ b/msa/js-executor/src/main/filters/windows.properties @@ -0,0 +1,2 @@ +pkg.logFolder=${BASE}\\logs +pkg.winWrapperLogFolder=%BASE%\\logs diff --git a/msa/js-executor/src/main/scripts/control/deb/postinst b/msa/js-executor/src/main/scripts/control/deb/postinst new file mode 100644 index 0000000000..0767d3f2c7 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/deb/postinst @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R ${pkg.user}: ${pkg.logFolder} +chown -R ${pkg.user}: ${pkg.installFolder} +update-rc.d ${pkg.name} defaults + diff --git a/msa/js-executor/src/main/scripts/control/deb/postrm b/msa/js-executor/src/main/scripts/control/deb/postrm new file mode 100644 index 0000000000..61865803c3 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/deb/postrm @@ -0,0 +1,3 @@ +#!/bin/sh + +update-rc.d -f ${pkg.name} remove diff --git a/msa/js-executor/src/main/scripts/control/deb/preinst b/msa/js-executor/src/main/scripts/control/deb/preinst new file mode 100644 index 0000000000..d2ebea46d7 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/deb/preinst @@ -0,0 +1,18 @@ +#!/bin/sh + +if ! getent group ${pkg.user} >/dev/null; then + addgroup --system ${pkg.user} +fi + +if ! getent passwd ${pkg.user} >/dev/null; then + adduser --quiet \ + --system \ + --ingroup ${pkg.user} \ + --quiet \ + --disabled-login \ + --disabled-password \ + --home ${pkg.installFolder} \ + --no-create-home \ + -gecos "Thingsboard application" \ + ${pkg.user} +fi diff --git a/msa/js-executor/src/main/scripts/control/deb/prerm b/msa/js-executor/src/main/scripts/control/deb/prerm new file mode 100644 index 0000000000..898d3efd5c --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/deb/prerm @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -e /var/run/${pkg.name}/${pkg.name}.pid ]; then + service ${pkg.name} stop +fi diff --git a/msa/js-executor/src/main/scripts/control/rpm/postinst b/msa/js-executor/src/main/scripts/control/rpm/postinst new file mode 100644 index 0000000000..d8021e2dd9 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/rpm/postinst @@ -0,0 +1,9 @@ +#!/bin/sh + +chown -R ${pkg.user}: ${pkg.logFolder} +chown -R ${pkg.user}: ${pkg.installFolder} + +if [ $1 -eq 1 ] ; then + # Initial installation + systemctl --no-reload enable ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/msa/js-executor/src/main/scripts/control/rpm/postrm b/msa/js-executor/src/main/scripts/control/rpm/postrm new file mode 100644 index 0000000000..8e1f8a2048 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/rpm/postrm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/msa/js-executor/src/main/scripts/control/rpm/preinst b/msa/js-executor/src/main/scripts/control/rpm/preinst new file mode 100644 index 0000000000..db6306e4ac --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/rpm/preinst @@ -0,0 +1,6 @@ +#!/bin/sh + +getent group ${pkg.user} >/dev/null || groupadd -r ${pkg.user} +getent passwd ${pkg.user} >/dev/null || \ +useradd -d ${pkg.installFolder} -g ${pkg.user} -M -r ${pkg.user} -s /sbin/nologin \ +-c "Thingsboard application" diff --git a/msa/js-executor/src/main/scripts/control/rpm/prerm b/msa/js-executor/src/main/scripts/control/rpm/prerm new file mode 100644 index 0000000000..accb487b8e --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/rpm/prerm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || : +fi diff --git a/msa/js-executor/src/main/scripts/control/tb-js-executor.service b/msa/js-executor/src/main/scripts/control/tb-js-executor.service new file mode 100644 index 0000000000..f542dd0f92 --- /dev/null +++ b/msa/js-executor/src/main/scripts/control/tb-js-executor.service @@ -0,0 +1,11 @@ +[Unit] +Description=${pkg.name} +After=syslog.target + +[Service] +User=${pkg.user} +ExecStart=${pkg.installFolder}/init/${pkg.name} +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/msa/js-executor/src/main/scripts/init/tb-js-executor b/msa/js-executor/src/main/scripts/init/tb-js-executor new file mode 100644 index 0000000000..6e8fa52cd3 --- /dev/null +++ b/msa/js-executor/src/main/scripts/init/tb-js-executor @@ -0,0 +1,233 @@ +#!/bin/bash +# + + +### BEGIN INIT INFO +# Provides: tb-js-executor +# Required-Start: $remote_fs $syslog $network +# Required-Stop: $remote_fs $syslog $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: ${project.name} +# Description: ${project.description} +# chkconfig: 2345 99 01 +### END INIT INFO + +[[ -n "$DEBUG" ]] && set -x + +# Initialize variables that cannot be provided by a .conf file +WORKING_DIR="$(pwd)" +# shellcheck disable=SC2153 + +mainfile=${pkg.installFolder}/bin/${pkg.name} +configfile=${pkg.name}.conf + +# Follow symlinks to find the real script and detect init.d script +cd "$(dirname "$0")" || exit 1 +[[ -z "$initfile" ]] && initfile=$(pwd)/$(basename "$0") +while [[ -L "$initfile" ]]; do + [[ "$initfile" =~ init\.d ]] && init_script=$(basename "$initfile") + initfile=$(readlink "$initfile") + cd "$(dirname "$initfile")" || exit 1 + initfile=$(pwd)/$(basename "$initfile") +done +initfolder="$( (cd "$(dirname "initfile")" && pwd -P) )" +cd "$WORKING_DIR" || exit 1 + +# Initialize CONF_FOLDER location +[[ -z "$CONF_FOLDER" ]] && CONF_FOLDER="${pkg.installFolder}/conf" + +# shellcheck source=/dev/null +[[ -r "${CONF_FOLDER}/${configfile}" ]] && source "${CONF_FOLDER}/${configfile}" + +# Initialize PID/LOG locations if they weren't provided by the config file +[[ -z "$PID_FOLDER" ]] && PID_FOLDER="/var/run" +[[ -z "$LOG_FOLDER" ]] && LOG_FOLDER="${pkg.unixLogFolder}" +! [[ "$PID_FOLDER" == /* ]] && PID_FOLDER="$(dirname "$mainfile")"/"$PID_FOLDER" +! [[ "$LOG_FOLDER" == /* ]] && LOG_FOLDER="$(dirname "$mainfile")"/"$LOG_FOLDER" +! [[ -x "$PID_FOLDER" ]] && PID_FOLDER="/tmp" +! [[ -x "$LOG_FOLDER" ]] && LOG_FOLDER="/tmp" + +# Set up defaults +[[ -z "$MODE" ]] && MODE="auto" # modes are "auto", "service" or "run" +[[ -z "$USE_START_STOP_DAEMON" ]] && USE_START_STOP_DAEMON="true" + +# Create an identity for log/pid files +if [[ -z "$identity" ]]; then + if [[ -n "$init_script" ]]; then + identity="${init_script}" + else + identity=$(basename "${initfile%.*}")_${initfolder//\//} + fi +fi + +# Initialize log file name if not provided by the config file +[[ -z "$LOG_FILENAME" ]] && LOG_FILENAME="${identity}.log" + +# ANSI Colors +echoRed() { echo $'\e[0;31m'"$1"$'\e[0m'; } +echoGreen() { echo $'\e[0;32m'"$1"$'\e[0m'; } +echoYellow() { echo $'\e[0;33m'"$1"$'\e[0m'; } + +# Utility functions +checkPermissions() { + touch "$pid_file" &> /dev/null || { echoRed "Operation not permitted (cannot access pid file)"; return 4; } + touch "$log_file" &> /dev/null || { echoRed "Operation not permitted (cannot access log file)"; return 4; } +} + +isRunning() { + ps -p "$1" &> /dev/null +} + +await_file() { + end=$(date +%s) + let "end+=10" + while [[ ! -s "$1" ]] + do + now=$(date +%s) + if [[ $now -ge $end ]]; then + break + fi + sleep 1 + done +} + +# Determine the script mode +action="run" +if [[ "$MODE" == "auto" && -n "$init_script" ]] || [[ "$MODE" == "service" ]]; then + action="$1" + shift +fi + +# Build the pid and log filenames +if [[ "$identity" == "$init_script" ]] || [[ "$identity" == "$APP_NAME" ]]; then + PID_FOLDER="$PID_FOLDER/${identity}" + pid_subfolder=$PID_FOLDER +fi +pid_file="$PID_FOLDER/${identity}.pid" +log_file="$LOG_FOLDER/$LOG_FILENAME" + +# Determine the user to run as if we are root +# shellcheck disable=SC2012 +[[ $(id -u) == "0" ]] && run_user=$(ls -ld "$mainfile" | awk '{print $3}') + +arguments=($RUN_ARGS "$@") + +# Action functions +start() { + if [[ -f "$pid_file" ]]; then + pid=$(cat "$pid_file") + isRunning "$pid" && { echoYellow "Already running [$pid]"; return 0; } + fi + do_start "$@" +} + +do_start() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + mkdir -p "$PID_FOLDER" &> /dev/null + if [[ -n "$run_user" ]]; then + checkPermissions || return $? + if [[ -z "$pid_subfolder" ]]; then + chown "$run_user" "$pid_subfolder" + fi + chown "$run_user" "$pid_file" + chown "$run_user" "$log_file" + if [ $USE_START_STOP_DAEMON = true ] && type start-stop-daemon > /dev/null 2>&1; then + start-stop-daemon --start --quiet \ + --chuid "$run_user" \ + --name "$identity" \ + --make-pidfile --pidfile "$pid_file" \ + --background --no-close \ + --startas "$mainfile" \ + --chdir "$working_dir" \ + -- "${arguments[@]}" \ + >> "$log_file" 2>&1 + await_file "$pid_file" + else + su -s /bin/sh -c "$mainfile $(printf "\"%s\" " "${arguments[@]}") >> \"$log_file\" 2>&1 & echo \$!" "$run_user" > "$pid_file" + fi + pid=$(cat "$pid_file") + else + checkPermissions || return $? + "$mainfile" "${arguments[@]}" >> "$log_file" 2>&1 & + pid=$! + disown $pid + echo "$pid" > "$pid_file" + fi + [[ -z $pid ]] && { echoRed "Failed to start"; return 1; } + echoGreen "Started [$pid]" +} + +stop() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f $pid_file ]] || { echoYellow "Not running (pidfile not found)"; return 0; } + pid=$(cat "$pid_file") + isRunning "$pid" || { echoYellow "Not running (process ${pid}). Removing stale pid file."; rm -f "$pid_file"; return 0; } + do_stop "$pid" "$pid_file" +} + +do_stop() { + kill -2 "$1" &> /dev/null || { echoRed "Unable to kill process $1"; return 1; } + for i in $(seq 1 60); do + isRunning "$1" || { echoGreen "Stopped [$1]"; rm -f "$2"; return 0; } + [[ $i -eq 30 ]] && kill -9 "$1" &> /dev/null + sleep 1 + done + echoRed "Unable to kill process $1"; + return 1; +} + +restart() { + stop && start +} + +orce_reload() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f $pid_file ]] || { echoRed "Not running (pidfile not found)"; return 7; } + pid=$(cat "$pid_file") + rm -f "$pid_file" + isRunning "$pid" || { echoRed "Not running (process ${pid} not found)"; return 7; } + do_stop "$pid" "$pid_file" + do_start +} + +status() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f "$pid_file" ]] || { echoRed "Not running"; return 3; } + pid=$(cat "$pid_file") + isRunning "$pid" || { echoRed "Not running (process ${pid} not found)"; return 1; } + echoGreen "Running [$pid]" + return 0 +} + +run() { + pushd "$(dirname "$mainfile")" > /dev/null + "$mainfile" "${arguments[@]}" + result=$? + popd > /dev/null + return "$result" +} + +# Call the appropriate action function +case "$action" in +start) + start "$@"; exit $?;; +stop) + stop "$@"; exit $?;; +restart) + restart "$@"; exit $?;; +force-reload) + force_reload "$@"; exit $?;; +status) + status "$@"; exit $?;; +run) + run "$@"; exit $?;; +*) + echo "Usage: $0 {start|stop|restart|force-reload|status|run}"; exit 1; +esac + +exit 0 diff --git a/msa/js-executor/src/main/scripts/windows/install.bat b/msa/js-executor/src/main/scripts/windows/install.bat new file mode 100644 index 0000000000..4da55425bb --- /dev/null +++ b/msa/js-executor/src/main/scripts/windows/install.bat @@ -0,0 +1,31 @@ +@REM +@REM Copyright © 2016-2018 The Thingsboard Authors +@REM +@REM Licensed under the Apache License, Version 2.0 (the "License"); +@REM you may not use this file except in compliance with the License. +@REM You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM + +@ECHO OFF + +setlocal ENABLEEXTENSIONS + +@ECHO Installing ${pkg.name} ... + +SET BASE=%~dp0 + +%BASE%${pkg.name}.exe install + +@ECHO ${pkg.name} installed successfully! + +GOTO END + +:END diff --git a/msa/js-executor/src/main/scripts/windows/service.xml b/msa/js-executor/src/main/scripts/windows/service.xml new file mode 100644 index 0000000000..bb91111586 --- /dev/null +++ b/msa/js-executor/src/main/scripts/windows/service.xml @@ -0,0 +1,29 @@ + + + ${pkg.name} + ${project.name} + ${project.description} + %BASE%\bin + ${pkg.winWrapperLogFolder} + rotate + + + + %BASE%\bin\${pkg.name}.exe + diff --git a/msa/js-executor/src/main/scripts/windows/uninstall.bat b/msa/js-executor/src/main/scripts/windows/uninstall.bat new file mode 100644 index 0000000000..7061d2a052 --- /dev/null +++ b/msa/js-executor/src/main/scripts/windows/uninstall.bat @@ -0,0 +1,25 @@ +@REM +@REM Copyright © 2016-2018 The Thingsboard Authors +@REM +@REM Licensed under the Apache License, Version 2.0 (the "License"); +@REM you may not use this file except in compliance with the License. +@REM You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM + +@ECHO OFF + +@ECHO Stopping ${pkg.name} ... +net stop ${pkg.name} + +@ECHO Uninstalling ${pkg.name} ... +%~dp0${pkg.name}.exe uninstall + +@ECHO DONE. \ No newline at end of file diff --git a/msa/pom.xml b/msa/pom.xml new file mode 100644 index 0000000000..5a9d9abd80 --- /dev/null +++ b/msa/pom.xml @@ -0,0 +1,59 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + thingsboard + + msa + pom + + ThingsBoard Microservices + https://thingsboard.io + + + ${basedir}/.. + thingsboard + true + + + + tb + js-executor + web-ui + tb-node + transport + black-box-tests + + + + + + + com.spotify + dockerfile-maven-plugin + 1.4.5 + + + + + + diff --git a/msa/tb-node/docker/Dockerfile b/msa/tb-node/docker/Dockerfile new file mode 100644 index 0000000000..57a6151e37 --- /dev/null +++ b/msa/tb-node/docker/Dockerfile @@ -0,0 +1,28 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jdk + +COPY start-tb-node.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb-node.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +CMD ["start-tb-node.sh"] diff --git a/msa/tb-node/docker/start-tb-node.sh b/msa/tb-node/docker/start-tb-node.sh new file mode 100755 index 0000000000..7f7e8696e2 --- /dev/null +++ b/msa/tb-node/docker/start-tb-node.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +CONF_FOLDER="/config" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf +run_user=${pkg.name} + +source "${CONF_FOLDER}/${configfile}" + +export LOADER_PATH=/config,${LOADER_PATH} + +if [ "$INSTALL_TB" == "true" ]; then + + if [ "$LOAD_DEMO" == "true" ]; then + loadDemo=true + else + loadDemo=false + fi + + echo "Starting ThingsBoard installation ..." + + exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardInstallApplication \ + -Dinstall.load_demo=${loadDemo} \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dinstall.upgrade=false \ + -Dlogging.config=/usr/share/thingsboard/bin/install/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +elif [ "$UPGRADE_TB" == "true" ]; then + + echo "Starting ThingsBoard upgrade ..." + + if [[ -z "${FROM_VERSION// }" ]]; then + echo "FROM_VERSION variable is invalid or unspecified!" + exit 1 + else + fromVersion="${FROM_VERSION// }" + fi + + exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardInstallApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dinstall.upgrade=true \ + -Dinstall.upgrade.from_version=${fromVersion} \ + -Dlogging.config=/usr/share/thingsboard/bin/install/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +else + + echo "Starting '${project.name}' ..." + + exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardServerApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=/config/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +fi diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml new file mode 100644 index 0000000000..fc62189e62 --- /dev/null +++ b/msa/tb-node/pom.xml @@ -0,0 +1,190 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + tb-node + pom + + ThingsBoard Node Microservice + https://thingsboard.io + ThingsBoard Node Microservice + + + UTF-8 + ${basedir}/../.. + thingsboard + tb-node + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + + + + + org.thingsboard + application + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-deb + package + + copy + + + + + org.thingsboard + application + deb + deb + ${pkg.name}.deb + ${project.build.directory} + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + + + + + + + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/tb/README.md b/msa/tb/README.md new file mode 100644 index 0000000000..e4b7400b50 --- /dev/null +++ b/msa/tb/README.md @@ -0,0 +1,83 @@ +# ThingsBoard single docker images + +This project provides the build for the ThingsBoard single docker images. + +* `thingsboard/tb` - single instance of ThingsBoard with embedded HSQLDB database. +* `thingsboard/tb-postgres` - single instance of ThingsBoard with PostgreSQL database. +* `thingsboard/tb-cassandra` - single instance of ThingsBoard with Cassandra database. + +## Running + +In this example `thingsboard/tb` image will be used. You can choose any other images with different databases (see above). +Execute the following command to run this docker directly: + +` +$ docker run -it -p 9090:9090 -p 1883:1883 -p 5683:5683/udp -v ~/.mytb-data:/data --name mytb thingsboard/tb +` + +Where: + +- `docker run` - run this container +- `-it` - attach a terminal session with current ThingsBoard process output +- `-p 9090:9090` - connect local port 9090 to exposed internal HTTP port 9090 +- `-p 1883:1883` - connect local port 1883 to exposed internal MQTT port 1883 +- `-p 5683:5683` - connect local port 5683 to exposed internal COAP port 5683 +- `-v ~/.mytb-data:/data` - mounts the host's dir `~/.mytb-data` to ThingsBoard DataBase data directory +- `--name mytb` - friendly local name of this machine +- `thingsboard/tb` - docker image, can be also `thingsboard/tb-postgres` or `thingsboard/tb-cassandra` + +> **NOTE**: **Windows** users should use docker managed volume instead of host's dir. Create docker volume (for ex. `mytb-data`) before executing `docker run` command: +> ``` +> $ docker create volume mytb-data +> ``` +> After you can execute docker run command using `mytb-data` volume instead of `~/.mytb-data`. +> In order to get access to necessary resources from external IP/Host on **Windows** machine, please execute the following commands: +> ``` +> $ VBoxManage controlvm "default" natpf1 "tcp-port9090,tcp,,9090,,9090" +> $ VBoxManage controlvm "default" natpf1 "tcp-port1883,tcp,,1883,,1883" +> $ VBoxManage controlvm "default" natpf1 "tcp-port5683,tcp,,5683,,5683" +> ``` + +After executing `docker run` command you can open `http://{your-host-ip}:9090` in you browser (for ex. `http://localhost:9090`). You should see ThingsBoard login page. +Use the following default credentials: + +- **System Administrator**: sysadmin@thingsboard.org / sysadmin +- **Tenant Administrator**: tenant@thingsboard.org / tenant +- **Customer User**: customer@thingsboard.org / customer + +You can always change passwords for each account in account profile page. + +You can detach from session terminal with `Ctrl-p` `Ctrl-q` - the container will keep running in the background. + +To reattach to the terminal (to see ThingsBoard logs) run: + +``` +$ docker attach mytb +``` + +To stop the container: + +``` +$ docker stop mytb +``` + +To start the container: + +``` +$ docker start mytb +``` + +## Upgrading + +In order to update to the latest image, execute the following commands: + +``` +$ docker pull thingsboard/tb +$ docker stop mytb +$ docker run -it -v ~/.mytb-data:/data --rm thingsboard/tb upgrade-tb.sh +$ docker start mytb +``` + +**NOTE**: if you use different database change image name in all commands from `thingsboard/tb` to `thingsboard/tb-postgres` or `thingsboard/tb-cassandra` correspondingly. + +**NOTE**: replace host's directory `~/.mytb-data` with directory used during container creation. diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile new file mode 100644 index 0000000000..a15408dddb --- /dev/null +++ b/msa/tb/docker-cassandra/Dockerfile @@ -0,0 +1,59 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jdk + +RUN apt-get update +RUN apt-get install -y curl nmap procps +RUN echo 'deb http://www.apache.org/dist/cassandra/debian 311x main' | tee --append /etc/apt/sources.list.d/cassandra.list > /dev/null +RUN curl https://www.apache.org/dist/cassandra/KEYS | apt-key add - +RUN apt-get update +RUN apt-get install -y cassandra cassandra-tools +RUN update-rc.d cassandra disable +RUN sed -i.old '/ulimit/d' /etc/init.d/cassandra + +COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb.sh /usr/bin \ + && mv /tmp/upgrade-tb.sh /usr/bin \ + && mv /tmp/install-tb.sh /usr/bin \ + && mv /tmp/start-db.sh /usr/bin \ + && mv /tmp/stop-db.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +ENV DATA_FOLDER=/data + +ENV HTTP_BIND_PORT=9090 +ENV DATABASE_TS_TYPE=cassandra +ENV DATABASE_ENTITIES_TYPE=cassandra + +ENV CASSANDRA_HOST=localhost +ENV CASSANDRA_PORT=9042 + +EXPOSE 9090 +EXPOSE 1883 +EXPOSE 5683/udp + +VOLUME ["/data"] + +CMD ["start-tb.sh"] diff --git a/docker/cassandra-setup/install.sh b/msa/tb/docker-cassandra/start-db.sh old mode 100755 new mode 100644 similarity index 62% rename from docker/cassandra-setup/install.sh rename to msa/tb/docker-cassandra/start-db.sh index 8578b2e769..c089bf65a6 --- a/docker/cassandra-setup/install.sh +++ b/msa/tb/docker-cassandra/start-db.sh @@ -15,19 +15,24 @@ # limitations under the License. # +cassandra_data_dir=${DATA_FOLDER}/db +cassandra_data_link=/var/lib/cassandra -dpkg -i /thingsboard.deb +if [ ! -L ${cassandra_data_link} ]; then + if [ -d ${cassandra_data_link} ]; then + rm -rf ${cassandra_data_link} + fi + if [ ! -d ${cassandra_data_dir} ]; then + mkdir -p ${cassandra_data_dir} + chown -R cassandra:cassandra ${cassandra_data_dir} + fi + ln -s ${cassandra_data_dir} ${cassandra_data_link} +fi + +service cassandra start until nmap $CASSANDRA_HOST -p $CASSANDRA_PORT | grep "$CASSANDRA_PORT/tcp open" do echo "Wait for cassandra db to start..." - sleep 10 + sleep 5 done - -echo "Creating 'Thingsboard' schema and system data..." -if [ "$ADD_DEMO_DATA" == "true" ]; then - echo "plus demo data..." - /usr/share/thingsboard/bin/install/install.sh --loadDemo -elif [ "$ADD_DEMO_DATA" == "false" ]; then - /usr/share/thingsboard/bin/install/install.sh -fi diff --git a/docker/docker-compose-tests.yml b/msa/tb/docker-cassandra/stop-db.sh similarity index 81% rename from docker/docker-compose-tests.yml rename to msa/tb/docker-cassandra/stop-db.sh index 64868b5a1f..8cb4b1210b 100644 --- a/docker/docker-compose-tests.yml +++ b/msa/tb/docker-cassandra/stop-db.sh @@ -1,3 +1,4 @@ +#!/bin/bash # # Copyright © 2016-2018 The Thingsboard Authors # @@ -14,14 +15,4 @@ # limitations under the License. # -version: '3.3' -services: - redis: - image: redis:4.0 - networks: - - core - ports: - - "6379:6379" - -networks: - core: +service cassandra stop diff --git a/msa/tb/docker-postgres/Dockerfile b/msa/tb/docker-postgres/Dockerfile new file mode 100644 index 0000000000..c1b3f029b0 --- /dev/null +++ b/msa/tb/docker-postgres/Dockerfile @@ -0,0 +1,62 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jdk + +RUN apt-get update +RUN apt-get install -y postgresql postgresql-contrib +RUN update-rc.d postgresql disable + +RUN mkdir -p /var/log/postgres +RUN chown -R postgres:postgres /var/log/postgres + +COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb.sh /usr/bin \ + && mv /tmp/upgrade-tb.sh /usr/bin \ + && mv /tmp/install-tb.sh /usr/bin \ + && mv /tmp/start-db.sh /usr/bin \ + && mv /tmp/stop-db.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +ENV DATA_FOLDER=/data + +ENV HTTP_BIND_PORT=9090 +ENV DATABASE_TS_TYPE=sql +ENV DATABASE_ENTITIES_TYPE=sql + +ENV PGDATA=/data/db + +ENV SPRING_JPA_DATABASE_PLATFORM=org.hibernate.dialect.PostgreSQLDialect +ENV SPRING_DRIVER_CLASS_NAME=org.postgresql.Driver +ENV SPRING_DATASOURCE_URL=jdbc:postgresql://localhost:5432/thingsboard +ENV SPRING_DATASOURCE_USERNAME=postgres +ENV SPRING_DATASOURCE_PASSWORD=postgres + +EXPOSE 9090 +EXPOSE 1883 +EXPOSE 5683/udp + +VOLUME ["/data"] + +CMD ["start-tb.sh"] diff --git a/docker/zookeeper/zk-ok.sh b/msa/tb/docker-postgres/start-db.sh old mode 100755 new mode 100644 similarity index 58% rename from docker/zookeeper/zk-ok.sh rename to msa/tb/docker-postgres/start-db.sh index 9f45db259d..4b973e6527 --- a/docker/zookeeper/zk-ok.sh +++ b/msa/tb/docker-postgres/start-db.sh @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/bash # # Copyright © 2016-2018 The Thingsboard Authors # @@ -15,14 +15,16 @@ # limitations under the License. # -# zk-ok.sh uses the ruok ZooKeeper four letter work to determine if the instance -# is health. The $? variable will be set to 0 if server responds that it is -# healthy, or 1 if the server fails to respond. +firstlaunch=${DATA_FOLDER}/.firstlaunch -ZK_CLIENT_PORT=${ZK_CLIENT_PORT:-2181} -OK=$(echo ruok | nc 127.0.0.1 $ZK_CLIENT_PORT) -if [ "$OK" == "imok" ]; then - exit 0 -else - exit 1 +if [ ! -d ${PGDATA} ]; then + mkdir -p ${PGDATA} + chown -R postgres:postgres ${PGDATA} + su postgres -c '/usr/lib/postgresql/9.6/bin/pg_ctl initdb -U postgres' +fi + +su postgres -c '/usr/lib/postgresql/9.6/bin/pg_ctl -l /var/log/postgres/postgres.log -w start' + +if [ ! -f ${firstlaunch} ]; then + su postgres -c 'psql -U postgres -d postgres -c "CREATE DATABASE thingsboard"' fi diff --git a/docker/docker-compose.static.yml b/msa/tb/docker-postgres/stop-db.sh similarity index 78% rename from docker/docker-compose.static.yml rename to msa/tb/docker-postgres/stop-db.sh index 4a0688a2da..b1db7caa9e 100644 --- a/docker/docker-compose.static.yml +++ b/msa/tb/docker-postgres/stop-db.sh @@ -1,3 +1,4 @@ +#!/bin/bash # # Copyright © 2016-2018 The Thingsboard Authors # @@ -14,16 +15,4 @@ # limitations under the License. # -version: '2' - -services: - cassandra: - ports: - - "9042:9042" - - "9160:9160" - zk: - ports: - - "2181:2181" - postgres: - ports: - - "5432:5432" \ No newline at end of file +su postgres -c '/usr/lib/postgresql/9.6/bin/pg_ctl stop' diff --git a/msa/tb/docker-tb/Dockerfile b/msa/tb/docker-tb/Dockerfile new file mode 100644 index 0000000000..497a03c959 --- /dev/null +++ b/msa/tb/docker-tb/Dockerfile @@ -0,0 +1,48 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jdk + +COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb.sh /usr/bin \ + && mv /tmp/upgrade-tb.sh /usr/bin \ + && mv /tmp/install-tb.sh /usr/bin \ + && mv /tmp/start-db.sh /usr/bin \ + && mv /tmp/stop-db.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +ENV DATA_FOLDER=/data + +ENV HTTP_BIND_PORT=9090 +ENV DATABASE_TS_TYPE=sql +ENV DATABASE_ENTITIES_TYPE=sql +ENV SQL_DATA_FOLDER=/data/db + +EXPOSE 9090 +EXPOSE 1883 +EXPOSE 5683/udp + +VOLUME ["/data"] + +CMD ["start-tb.sh"] diff --git a/msa/tb/docker-tb/start-db.sh b/msa/tb/docker-tb/start-db.sh new file mode 100644 index 0000000000..779e69c3eb --- /dev/null +++ b/msa/tb/docker-tb/start-db.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Do nothing \ No newline at end of file diff --git a/msa/tb/docker-tb/stop-db.sh b/msa/tb/docker-tb/stop-db.sh new file mode 100644 index 0000000000..779e69c3eb --- /dev/null +++ b/msa/tb/docker-tb/stop-db.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Do nothing \ No newline at end of file diff --git a/msa/tb/docker/install-tb.sh b/msa/tb/docker/install-tb.sh new file mode 100644 index 0000000000..e22ec58ec2 --- /dev/null +++ b/msa/tb/docker/install-tb.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +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 + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf +upgradeversion=${DATA_FOLDER}/.upgradeversion + +source "${CONF_FOLDER}/${configfile}" + +echo "Starting ThingsBoard installation ..." + +java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardInstallApplication \ + -Dinstall.load_demo=${loadDemo} \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dinstall.upgrade=false \ + -Dlogging.config=/usr/share/thingsboard/bin/install/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +echo "${pkg.upgradeVersion}" > ${upgradeversion} diff --git a/msa/tb/docker/logback.xml b/msa/tb/docker/logback.xml new file mode 100644 index 0000000000..87f9bf4c06 --- /dev/null +++ b/msa/tb/docker/logback.xml @@ -0,0 +1,51 @@ + + + + + + + /var/log/thingsboard/thingsboard.log + + /var/log/thingsboard/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 + + + + + + + + + + + + \ No newline at end of file diff --git a/msa/tb/docker/start-tb.sh b/msa/tb/docker/start-tb.sh new file mode 100755 index 0000000000..37f5e3a72a --- /dev/null +++ b/msa/tb/docker/start-tb.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +start-db.sh + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf +firstlaunch=${DATA_FOLDER}/.firstlaunch + +source "${CONF_FOLDER}/${configfile}" + +if [ ! -f ${firstlaunch} ]; then + install-tb.sh --loadDemo + touch ${firstlaunch} +fi + +echo "Starting ThingsBoard ..." + +java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardServerApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=${CONF_FOLDER}/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +stop-db.sh \ No newline at end of file diff --git a/msa/tb/docker/thingsboard.conf b/msa/tb/docker/thingsboard.conf new file mode 100644 index 0000000000..85d7cfd871 --- /dev/null +++ b/msa/tb/docker/thingsboard.conf @@ -0,0 +1,24 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Dplatform=deb -Dinstall.data_dir=/usr/share/thingsboard/data" +export JAVA_OPTS="$JAVA_OPTS -Xloggc:/var/log/thingsboard/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -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 diff --git a/msa/tb/docker/upgrade-tb.sh b/msa/tb/docker/upgrade-tb.sh new file mode 100644 index 0000000000..ab77cddf4c --- /dev/null +++ b/msa/tb/docker/upgrade-tb.sh @@ -0,0 +1,47 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +start-db.sh + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf +upgradeversion=${DATA_FOLDER}/.upgradeversion + +source "${CONF_FOLDER}/${configfile}" + +FROM_VERSION=`cat ${upgradeversion}` + +echo "Starting ThingsBoard upgrade ..." + +if [[ -z "${FROM_VERSION// }" ]]; then + echo "FROM_VERSION variable is invalid or unspecified!" + exit 1 +else + fromVersion="${FROM_VERSION// }" +fi + +java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.ThingsboardInstallApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dinstall.upgrade=true \ + -Dinstall.upgrade.from_version=${fromVersion} \ + -Dlogging.config=/usr/share/thingsboard/bin/install/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher + +echo "${pkg.upgradeVersion}" > ${upgradeversion} + +stop-db.sh \ No newline at end of file diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml new file mode 100644 index 0000000000..6ea7bebae2 --- /dev/null +++ b/msa/tb/pom.xml @@ -0,0 +1,370 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + tb + pom + + ThingsBoard Docker Images + https://thingsboard.io + ThingsBoard Docker Images + + + UTF-8 + ${basedir}/../.. + thingsboard + tb + tb-postgres + tb-cassandra + thingsboard + /usr/share/${pkg.name} + 2.1.1 + + + + + org.thingsboard + application + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-deb + package + + copy + + + + + org.thingsboard + application + deb + deb + ${pkg.name}.deb + ${project.build.directory}/docker-tb + + + + + + copy-tb-postgres-deb + package + + copy + + + + + org.thingsboard + application + deb + deb + ${pkg.name}.deb + ${project.build.directory}/docker-postgres + + + + + + copy-tb-cassandra-deb + package + + copy + + + + + org.thingsboard + application + deb + deb + ${pkg.name}.deb + ${project.build.directory}/docker-cassandra + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-tb-config + process-resources + + copy-resources + + + ${project.build.directory}/docker-tb + + + docker + true + + + docker-tb + true + + + + + + copy-docker-tb-postgres-config + process-resources + + copy-resources + + + ${project.build.directory}/docker-postgres + + + docker + true + + + docker-postgres + true + + + + + + copy-docker-tb-cassandra-config + process-resources + + copy-resources + + + ${project.build.directory}/docker-cassandra + + + docker + true + + + docker-cassandra + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-tb-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${tb.docker.name} + true + false + ${project.build.directory}/docker-tb + + + + tag-docker-tb-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${tb.docker.name} + ${project.version} + + + + build-docker-tb-postgres-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${tb-postgres.docker.name} + true + false + ${project.build.directory}/docker-postgres + + + + tag-docker-tb-postgres-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${tb-postgres.docker.name} + ${project.version} + + + + build-docker-tb-cassandra-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${tb-cassandra.docker.name} + true + false + ${project.build.directory}/docker-cassandra + + + + tag-docker-tb-cassandra-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${tb-cassandra.docker.name} + ${project.version} + + + + + + + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-tb-image + pre-integration-test + + push + + + latest + ${docker.repo}/${tb.docker.name} + + + + push-version-docker-tb-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${tb.docker.name} + + + + push-latest-docker-tb-postgres-image + pre-integration-test + + push + + + latest + ${docker.repo}/${tb-postgres.docker.name} + + + + push-version-docker-tb-postgres-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${tb-postgres.docker.name} + + + + push-latest-docker-tb-cassandra-image + pre-integration-test + + push + + + latest + ${docker.repo}/${tb-cassandra.docker.name} + + + + push-version-docker-tb-cassandra-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${tb-cassandra.docker.name} + + + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/transport/coap/docker/Dockerfile b/msa/transport/coap/docker/Dockerfile new file mode 100644 index 0000000000..9240b2a4f0 --- /dev/null +++ b/msa/transport/coap/docker/Dockerfile @@ -0,0 +1,31 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jdk + +COPY logback.xml ${pkg.name}.conf start-tb-coap-transport.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb-coap-transport.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +CMD ["start-tb-coap-transport.sh"] diff --git a/msa/transport/coap/docker/logback.xml b/msa/transport/coap/docker/logback.xml new file mode 100644 index 0000000000..e9d86928bd --- /dev/null +++ b/msa/transport/coap/docker/logback.xml @@ -0,0 +1,50 @@ + + + + + + + /var/log/${pkg.name}/${pkg.name}.log + + /var/log/${pkg.name}/${pkg.name}.%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 + + + + + + + + + + + \ No newline at end of file diff --git a/msa/transport/coap/docker/start-tb-coap-transport.sh b/msa/transport/coap/docker/start-tb-coap-transport.sh new file mode 100755 index 0000000000..43d4602230 --- /dev/null +++ b/msa/transport/coap/docker/start-tb-coap-transport.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf + +source "${CONF_FOLDER}/${configfile}" + +echo "Starting '${project.name}' ..." + +exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.coap.ThingsboardCoapTransportApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=${CONF_FOLDER}/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher diff --git a/msa/transport/coap/docker/tb-coap-transport.conf b/msa/transport/coap/docker/tb-coap-transport.conf new file mode 100644 index 0000000000..6d4c54aad1 --- /dev/null +++ b/msa/transport/coap/docker/tb-coap-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -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=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml new file mode 100644 index 0000000000..26eaa30e0a --- /dev/null +++ b/msa/transport/coap/pom.xml @@ -0,0 +1,190 @@ + + + 4.0.0 + + org.thingsboard.msa + 2.2.0-SNAPSHOT + transport + + org.thingsboard.msa.transport + coap + pom + + ThingsBoard COAP Transport Microservice + https://thingsboard.io + ThingsBoard COAP Transport Microservice + + + UTF-8 + ${basedir}/../../.. + tb-coap-transport + tb-coap-transport + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + + + + + org.thingsboard.transport + coap + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-coap-transport-deb + package + + copy + + + + + org.thingsboard.transport + coap + deb + deb + ${pkg.name}.deb + ${project.build.directory} + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + + + + + + + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/transport/http/docker/Dockerfile b/msa/transport/http/docker/Dockerfile new file mode 100644 index 0000000000..6c83b9c76b --- /dev/null +++ b/msa/transport/http/docker/Dockerfile @@ -0,0 +1,31 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jdk + +COPY logback.xml ${pkg.name}.conf start-tb-http-transport.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb-http-transport.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +CMD ["start-tb-http-transport.sh"] diff --git a/msa/transport/http/docker/logback.xml b/msa/transport/http/docker/logback.xml new file mode 100644 index 0000000000..e9d86928bd --- /dev/null +++ b/msa/transport/http/docker/logback.xml @@ -0,0 +1,50 @@ + + + + + + + /var/log/${pkg.name}/${pkg.name}.log + + /var/log/${pkg.name}/${pkg.name}.%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 + + + + + + + + + + + \ No newline at end of file diff --git a/msa/transport/http/docker/start-tb-http-transport.sh b/msa/transport/http/docker/start-tb-http-transport.sh new file mode 100755 index 0000000000..667988fa42 --- /dev/null +++ b/msa/transport/http/docker/start-tb-http-transport.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf + +source "${CONF_FOLDER}/${configfile}" + +echo "Starting '${project.name}' ..." + +exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.http.ThingsboardHttpTransportApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=${CONF_FOLDER}/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher diff --git a/msa/transport/http/docker/tb-http-transport.conf b/msa/transport/http/docker/tb-http-transport.conf new file mode 100644 index 0000000000..6d4c54aad1 --- /dev/null +++ b/msa/transport/http/docker/tb-http-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -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=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml new file mode 100644 index 0000000000..6d1707c4f7 --- /dev/null +++ b/msa/transport/http/pom.xml @@ -0,0 +1,190 @@ + + + 4.0.0 + + org.thingsboard.msa + 2.2.0-SNAPSHOT + transport + + org.thingsboard.msa.transport + http + pom + + ThingsBoard HTTP Transport Microservice + https://thingsboard.io + ThingsBoard HTTP Transport Microservice + + + UTF-8 + ${basedir}/../../.. + tb-http-transport + tb-http-transport + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + + + + + org.thingsboard.transport + http + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-http-transport-deb + package + + copy + + + + + org.thingsboard.transport + http + deb + deb + ${pkg.name}.deb + ${project.build.directory} + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + + + + + + + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/transport/mqtt/docker/Dockerfile b/msa/transport/mqtt/docker/Dockerfile new file mode 100644 index 0000000000..f636e2f3a7 --- /dev/null +++ b/msa/transport/mqtt/docker/Dockerfile @@ -0,0 +1,31 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM openjdk:8-jdk + +COPY logback.xml ${pkg.name}.conf start-tb-mqtt-transport.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-tb-mqtt-transport.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +RUN mv /tmp/logback.xml ${pkg.installFolder}/conf \ + && mv /tmp/${pkg.name}.conf ${pkg.installFolder}/conf + +CMD ["start-tb-mqtt-transport.sh"] diff --git a/msa/transport/mqtt/docker/logback.xml b/msa/transport/mqtt/docker/logback.xml new file mode 100644 index 0000000000..e9d86928bd --- /dev/null +++ b/msa/transport/mqtt/docker/logback.xml @@ -0,0 +1,50 @@ + + + + + + + /var/log/${pkg.name}/${pkg.name}.log + + /var/log/${pkg.name}/${pkg.name}.%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 + + + + + + + + + + + \ No newline at end of file diff --git a/msa/transport/mqtt/docker/start-tb-mqtt-transport.sh b/msa/transport/mqtt/docker/start-tb-mqtt-transport.sh new file mode 100755 index 0000000000..8fe06d272e --- /dev/null +++ b/msa/transport/mqtt/docker/start-tb-mqtt-transport.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +CONF_FOLDER="${pkg.installFolder}/conf" +jarfile=${pkg.installFolder}/bin/${pkg.name}.jar +configfile=${pkg.name}.conf + +source "${CONF_FOLDER}/${configfile}" + +echo "Starting '${project.name}' ..." + +exec java -cp ${jarfile} $JAVA_OPTS -Dloader.main=org.thingsboard.server.mqtt.ThingsboardMqttTransportApplication \ + -Dspring.jpa.hibernate.ddl-auto=none \ + -Dlogging.config=${CONF_FOLDER}/logback.xml \ + org.springframework.boot.loader.PropertiesLauncher diff --git a/msa/transport/mqtt/docker/tb-mqtt-transport.conf b/msa/transport/mqtt/docker/tb-mqtt-transport.conf new file mode 100644 index 0000000000..6d4c54aad1 --- /dev/null +++ b/msa/transport/mqtt/docker/tb-mqtt-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -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=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml new file mode 100644 index 0000000000..d51c51c035 --- /dev/null +++ b/msa/transport/mqtt/pom.xml @@ -0,0 +1,190 @@ + + + 4.0.0 + + org.thingsboard.msa + 2.2.0-SNAPSHOT + transport + + org.thingsboard.msa.transport + mqtt + pom + + ThingsBoard MQTT Transport Microservice + https://thingsboard.io + ThingsBoard MQTT Transport Microservice + + + UTF-8 + ${basedir}/../../.. + tb-mqtt-transport + tb-mqtt-transport + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + + + + + org.thingsboard.transport + mqtt + ${project.version} + deb + deb + provided + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-tb-mqtt-transport-deb + package + + copy + + + + + org.thingsboard.transport + mqtt + deb + deb + ${pkg.name}.deb + ${project.build.directory} + + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + + + + + + + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml new file mode 100644 index 0000000000..9f091e3d20 --- /dev/null +++ b/msa/transport/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + transport + pom + + ThingsBoard Transport Microservices + https://thingsboard.io + + + ${basedir}/../.. + + + + mqtt + http + coap + + + + + + + com.spotify + dockerfile-maven-plugin + 1.4.5 + + + + + + diff --git a/msa/web-ui/.gitignore b/msa/web-ui/.gitignore new file mode 100644 index 0000000000..c5cfd3d322 --- /dev/null +++ b/msa/web-ui/.gitignore @@ -0,0 +1,31 @@ +*.toDelete +output/** +*.class +*~ +*.iml +*/.idea/** +.idea/** +.idea +*.log +*.log.[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] +*/.classpath +.classpath +*/.project +.project +.cache/** +target/ +logs/ +build/ +.settings/ +/bin +bin/ +**/dependency-reduced-pom.xml +pom.xml.versionsBackup +.DS_Store +**/.gradle +**/local.properties +**/build +**/target +**/.env +node_modules +package-lock.json diff --git a/msa/web-ui/build.gradle b/msa/web-ui/build.gradle new file mode 100644 index 0000000000..7372c0a819 --- /dev/null +++ b/msa/web-ui/build.gradle @@ -0,0 +1,125 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +buildscript { + ext { + osPackageVersion = "3.8.0" + } + repositories { + jcenter() + } + dependencies { + classpath("com.netflix.nebula:gradle-ospackage-plugin:${osPackageVersion}") + } +} + +apply plugin: "nebula.ospackage" + +buildDir = projectBuildDir +version = projectVersion +distsDirName = "./" + +// OS Package plugin configuration +ospackage { + packageName = pkgName + version = "${project.version}" + release = 1 + os = LINUX + type = BINARY + + into pkgInstallFolder + + user pkgUser + permissionGroup pkgUser + + // Copy the executable file + from("target/package/linux/bin/${pkgName}") { + fileMode 0500 + into "bin" + } + + // Copy the init file + from("target/package/linux/init/${pkgName}") { + fileMode 0500 + into "init" + } + + // Copy the config files + from("target/package/linux/conf") { + fileType CONFIG | NOREPLACE + fileMode 0754 + into "conf" + } + + // Copy web files + from("target/package/linux/web") { + into "web" + } + +} + +// Configure our RPM build task +buildRpm { + + arch = X86_64 + + version = projectVersion.replace('-', '') + archiveName = "${pkgName}.rpm" + + preInstall file("${buildDir}/control/rpm/preinst") + postInstall file("${buildDir}/control/rpm/postinst") + preUninstall file("${buildDir}/control/rpm/prerm") + postUninstall file("${buildDir}/control/rpm/postrm") + + user pkgUser + permissionGroup pkgUser + + // Copy the system unit files + from("${buildDir}/control/${pkgName}.service") { + addParentDirs = false + fileMode 0644 + into "/usr/lib/systemd/system" + } + + directory(pkgLogFolder, 0755) + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} + +// Same as the buildRpm task +buildDeb { + + arch = "amd64" + + archiveName = "${pkgName}.deb" + + configurationFile("${pkgInstallFolder}/conf/${pkgName}.conf") + configurationFile("${pkgInstallFolder}/conf/custom-environment-variables.yml") + configurationFile("${pkgInstallFolder}/conf/default.yml") + configurationFile("${pkgInstallFolder}/conf/logger.js") + + preInstall file("${buildDir}/control/deb/preinst") + postInstall file("${buildDir}/control/deb/postinst") + preUninstall file("${buildDir}/control/deb/prerm") + postUninstall file("${buildDir}/control/deb/postrm") + + user pkgUser + permissionGroup pkgUser + + directory(pkgLogFolder, 0755) + link("/etc/init.d/${pkgName}", "${pkgInstallFolder}/init/${pkgName}") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} diff --git a/msa/web-ui/config/custom-environment-variables.yml b/msa/web-ui/config/custom-environment-variables.yml new file mode 100644 index 0000000000..9472a50571 --- /dev/null +++ b/msa/web-ui/config/custom-environment-variables.yml @@ -0,0 +1,30 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +server: + # Server bind address + address: "HTTP_BIND_ADDRESS" + # Server bind port + port: "HTTP_BIND_PORT" +thingsboard: + # ThingsBoard node host + host: "TB_HOST" + # ThingsBoard node port + port: "TB_PORT" +logger: + level: "LOGGER_LEVEL" + path: "LOG_FOLDER" + filename: "LOGGER_FILENAME" diff --git a/msa/web-ui/config/default.yml b/msa/web-ui/config/default.yml new file mode 100644 index 0000000000..cf27a14015 --- /dev/null +++ b/msa/web-ui/config/default.yml @@ -0,0 +1,30 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +server: + # Server bind address + address: "0.0.0.0" + # Server bind port + port: "8090" +thingsboard: + # ThingsBoard node host + host: "localhost" + # ThingsBoard node port + port: "8080" +logger: + level: "info" + path: "logs" + filename: "tb-web-ui-%DATE%.log" diff --git a/msa/web-ui/config/logger.js b/msa/web-ui/config/logger.js new file mode 100644 index 0000000000..695b453763 --- /dev/null +++ b/msa/web-ui/config/logger.js @@ -0,0 +1,59 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +var config = require('config'), + path = require('path'), + DailyRotateFile = require('winston-daily-rotate-file'); + +const { createLogger, format, transports } = require('winston'); +const { combine, timestamp, label, printf, splat } = format; + +var loggerTransports = []; + +if (process.env.NODE_ENV !== 'production' || process.env.DOCKER_MODE === 'true') { + loggerTransports.push(new transports.Console({ + handleExceptions: true + })); +} else { + var filename = path.join(config.get('logger.path'), config.get('logger.filename')); + var transport = new (DailyRotateFile)({ + filename: filename, + datePattern: 'YYYY-MM-DD-HH', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + handleExceptions: true + }); + loggerTransports.push(transport); +} + +const tbFormat = printf(info => { + return `${info.timestamp} [${info.label}] ${info.level.toUpperCase()}: ${info.message}`; +}); + +function _logger(moduleLabel) { + return createLogger({ + level: config.get('logger.level'), + format:combine( + splat(), + label({ label: moduleLabel }), + timestamp({format: 'YYYY-MM-DD HH:mm:ss,SSS'}), + tbFormat + ), + transports: loggerTransports + }); +} + +module.exports = _logger; \ No newline at end of file diff --git a/msa/web-ui/config/tb-web-ui.conf b/msa/web-ui/config/tb-web-ui.conf new file mode 100644 index 0000000000..d002038d40 --- /dev/null +++ b/msa/web-ui/config/tb-web-ui.conf @@ -0,0 +1,20 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export NODE_CONFIG_DIR=${pkg.installFolder}/conf +export LOG_FOLDER=${pkg.logFolder} +export NODE_ENV=production +export WEB_FOLDER=${pkg.installFolder}/web \ No newline at end of file diff --git a/msa/web-ui/docker/Dockerfile b/msa/web-ui/docker/Dockerfile new file mode 100644 index 0000000000..a4c4ff2976 --- /dev/null +++ b/msa/web-ui/docker/Dockerfile @@ -0,0 +1,28 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM debian:stretch + +COPY start-web-ui.sh ${pkg.name}.deb /tmp/ + +RUN chmod a+x /tmp/*.sh \ + && mv /tmp/start-web-ui.sh /usr/bin + +RUN dpkg -i /tmp/${pkg.name}.deb + +RUN update-rc.d ${pkg.name} disable + +CMD ["start-web-ui.sh"] diff --git a/msa/web-ui/docker/start-web-ui.sh b/msa/web-ui/docker/start-web-ui.sh new file mode 100755 index 0000000000..af7c686a11 --- /dev/null +++ b/msa/web-ui/docker/start-web-ui.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + + +echo "Starting '${project.name}' ..." + +CONF_FOLDER="${pkg.installFolder}/conf" + +mainfile=${pkg.installFolder}/bin/${pkg.name} +configfile=${pkg.name}.conf +identity=${pkg.name} + +source "${CONF_FOLDER}/${configfile}" + +su -s /bin/sh -c "$mainfile" diff --git a/msa/web-ui/install.js b/msa/web-ui/install.js new file mode 100644 index 0000000000..63e73dbe56 --- /dev/null +++ b/msa/web-ui/install.js @@ -0,0 +1,42 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +const fs = require('fs'); +const fse = require('fs-extra'); +const path = require('path'); + +let _projectRoot = null; + + +(async() => { + await fse.move(path.join(projectRoot(), 'target', 'thingsboard-web-ui-linux'), + path.join(targetPackageDir('linux'), 'bin', 'tb-web-ui'), + {overwrite: true}); + await fse.move(path.join(projectRoot(), 'target', 'thingsboard-web-ui-win.exe'), + path.join(targetPackageDir('windows'), 'bin', 'tb-web-ui.exe'), + {overwrite: true}); +})(); + + +function projectRoot() { + if (!_projectRoot) { + _projectRoot = __dirname; + } + return _projectRoot; +} + +function targetPackageDir(platform) { + return path.join(projectRoot(), 'target', 'package', platform); +} diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json new file mode 100644 index 0000000000..f30f2fc3a6 --- /dev/null +++ b/msa/web-ui/package.json @@ -0,0 +1,38 @@ +{ + "name": "thingsboard-web-ui", + "private": true, + "version": "2.2.0", + "description": "ThingsBoard Web UI Microservice", + "main": "server.js", + "bin": "server.js", + "scripts": { + "install": "pkg -t node8-linux-x64,node8-win-x64 --out-path ./target . && node install.js", + "test": "echo \"Error: no test specified\" && exit 1", + "start": "WEB_FOLDER=./target/web nodemon server.js", + "start-prod": "NODE_ENV=production nodemon server.js" + }, + "dependencies": { + "config": "^1.30.0", + "connect-history-api-fallback": "^1.5.0", + "express": "^4.16.3", + "http": "0.0.0", + "http-proxy": "^1.17.0", + "js-yaml": "^3.12.0", + "winston": "^3.0.0", + "winston-daily-rotate-file": "^3.2.1" + }, + "engine": "node >= 5.9.0", + "nyc": { + "exclude": [ + "test", + "__tests__", + "node_modules", + "target" + ] + }, + "devDependencies": { + "fs-extra": "^6.0.1", + "nodemon": "^1.17.5", + "pkg": "^4.3.3" + } +} diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml new file mode 100644 index 0000000000..83124dff06 --- /dev/null +++ b/msa/web-ui/pom.xml @@ -0,0 +1,423 @@ + + + 4.0.0 + + org.thingsboard + 2.2.0-SNAPSHOT + msa + + org.thingsboard.msa + web-ui + pom + + ThingsBoard Web UI Microservice + https://thingsboard.io + Service for hosting ThingsBoard Web UI + + + UTF-8 + ${basedir}/../.. + tb-web-ui + tb-web-ui + thingsboard + /var/log/${pkg.name} + /usr/share/${pkg.name} + ${project.build.directory}/package/linux + ${project.build.directory}/package/windows + + + + + org.thingsboard + ui + ${project.version} + jar + provided + + + com.sun.winsw + winsw + bin + exe + provided + + + + + + + com.github.eirslett + frontend-maven-plugin + 1.0 + + target + ${basedir} + + + + install node and npm + + install-node-and-npm + + + v8.11.3 + 5.6.0 + + + + npm install + + npm + + + install + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + extract-web-ui + + unpack + + + + + org.thingsboard + ui + jar + false + ${project.build.directory}/web + + + + + + copy-winsw-service + package + + copy + + + + + com.sun.winsw + winsw + bin + exe + service.exe + + + ${pkg.win.dist} + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-linux-conf + process-resources + + copy-resources + + + ${pkg.linux.dist}/conf + + + config + true + + + + src/main/filters/unix.properties + + + + + copy-linux-init + process-resources + + copy-resources + + + ${pkg.linux.dist}/init + + + src/main/scripts/init + true + + + + src/main/filters/unix.properties + + + + + copy-win-conf + process-resources + + copy-resources + + + ${pkg.win.dist}/conf + + + config + + tb-web-ui.conf + + true + + + + src/main/filters/windows.properties + + + + + copy-control + process-resources + + copy-resources + + + ${project.build.directory}/control + + + src/main/scripts/control + true + + + + src/main/filters/unix.properties + + + + + copy-windows-control + process-resources + + copy-resources + + + ${pkg.win.dist} + + + src/main/scripts/windows + true + + + + src/main/filters/windows.properties + + + + + copy-docker-config + process-resources + + copy-resources + + + ${project.build.directory} + + + docker + true + + + + + + + + org.fortasoft + gradle-maven-plugin + + + build + buildDeb + buildRpm + + + -PprojectBuildDir=${project.build.directory} + -PprojectVersion=${project.version} + -PpkgName=${pkg.name} + -PpkgUser=${pkg.user} + -PpkgInstallFolder=${pkg.installFolder} + -PpkgLogFolder=${pkg.unixLogFolder} + + + + + package + + invoke + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.0.0 + + ${pkg.name} + + src/main/assembly/windows.xml + + + + + assembly + package + + single + + + + + + com.spotify + dockerfile-maven-plugin + + + build-docker-image + pre-integration-test + + build + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + true + false + ${project.build.directory} + + + + tag-docker-image + pre-integration-test + + tag + + + ${dockerfile.skip} + ${docker.repo}/${docker.name} + ${project.version} + + + + + + + + + npm-start + + + npm-start + + + + + + com.github.eirslett + frontend-maven-plugin + 1.0 + + target + ${basedir} + + + + npm start + + npm + + + + start + + + + + + + + + push-docker-image + + + push-docker-image + + + + + + com.spotify + dockerfile-maven-plugin + + + push-latest-docker-image + pre-integration-test + + push + + + latest + ${docker.repo}/${docker.name} + + + + push-version-docker-image + pre-integration-test + + push + + + ${project.version} + ${docker.repo}/${docker.name} + + + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + + diff --git a/msa/web-ui/server.js b/msa/web-ui/server.js new file mode 100644 index 0000000000..44e175a84c --- /dev/null +++ b/msa/web-ui/server.js @@ -0,0 +1,129 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const config = require('config'), + logger = require('./config/logger')('main'), + express = require('express'), + http = require('http'), + httpProxy = require('http-proxy'), + path = require('path'), + historyApiFallback = require("connect-history-api-fallback"); + +var server; + +(async() => { + try { + logger.info('Starting ThingsBoard Web UI Microservice...'); + + const bindAddress = config.get('server.address'); + const bindPort = config.get('server.port'); + + const thingsboardHost = config.get('thingsboard.host'); + const thingsboardPort = config.get('thingsboard.port'); + + logger.info('Bind address: %s', bindAddress); + logger.info('Bind port: %s', bindPort); + logger.info('ThingsBoard host: %s', thingsboardHost); + logger.info('ThingsBoard port: %s', thingsboardPort); + + var webDir = path.join(__dirname, 'web'); + + if (typeof process.env.WEB_FOLDER === 'string') { + webDir = path.resolve(process.env.WEB_FOLDER); + } + logger.info('Web folder: %s', webDir); + + const app = express(); + server = http.createServer(app); + + const apiProxy = httpProxy.createProxyServer({ + target: { + host: thingsboardHost, + port: thingsboardPort + } + }); + + apiProxy.on('error', function (err, req, res) { + logger.warn('API proxy error: %s', err.message); + res.writeHead(500); + if (err.code && err.code === 'ECONNREFUSED') { + res.end('Unable to connect to ThingsBoard server.'); + } else { + res.end('Thingsboard server connection error: ' + err.code ? err.code : ''); + } + }); + + const root = path.join(webDir, 'public'); + + const staticDir = path.join(root, 'static'); + + app.all('/api/*', (req, res) => { + logger.debug(req.method + ' ' + req.originalUrl); + apiProxy.web(req, res); + }); + + app.all('/static/rulenode/*', (req, res) => { + apiProxy.web(req, res); + }); + + app.use(historyApiFallback()); + + app.use('/static', express.static(staticDir)); + + app.get('*', (req, res) => { + apiProxy.web(req, res); + }); + + server.on('upgrade', (req, socket, head) => { + apiProxy.ws(req, socket, head); + }); + + server.listen(bindPort, bindAddress, (error) => { + if (error) { + logger.error('Failed to start ThingsBoard Web UI Microservice: %s', e.message); + logger.error(error.stack); + exit(-1); + } else { + logger.info('==> 🌎 Listening on port %s.', bindPort); + logger.info('Started ThingsBoard Web UI Microservice.'); + } + }); + + } catch (e) { + logger.error('Failed to start ThingsBoard Web UI Microservice: %s', e.message); + logger.error(e.stack); + exit(-1); + } +})(); + +process.on('exit', function () { + exit(0); +}); + +function exit(status) { + logger.info('Exiting with status: %d ...', status); + if (server) { + logger.info('Stopping HTTP Server...'); + var _server = server; + server = null; + _server.close(() => { + logger.info('HTTP Server stopped.'); + process.exit(status); + }); + } else { + process.exit(status); + } +} diff --git a/msa/web-ui/src/main/assembly/windows.xml b/msa/web-ui/src/main/assembly/windows.xml new file mode 100644 index 0000000000..12119906c1 --- /dev/null +++ b/msa/web-ui/src/main/assembly/windows.xml @@ -0,0 +1,75 @@ + + + windows + + + zip + + + + + + ${pkg.win.dist} + logs + + */** + + + + ${pkg.win.dist}/conf + conf + windows + + + ${project.build.directory}/web + web + + + + + + ${pkg.win.dist}/bin/${pkg.name}.exe + bin + ${pkg.name}.exe + + + ${pkg.win.dist}/service.exe + + ${pkg.name}.exe + + + ${pkg.win.dist}/service.xml + + ${pkg.name}.xml + windows + + + ${pkg.win.dist}/install.bat + + windows + + + ${pkg.win.dist}/uninstall.bat + + windows + + + diff --git a/msa/web-ui/src/main/filters/unix.properties b/msa/web-ui/src/main/filters/unix.properties new file mode 100644 index 0000000000..8967278673 --- /dev/null +++ b/msa/web-ui/src/main/filters/unix.properties @@ -0,0 +1 @@ +pkg.logFolder=${pkg.unixLogFolder} \ No newline at end of file diff --git a/msa/web-ui/src/main/filters/windows.properties b/msa/web-ui/src/main/filters/windows.properties new file mode 100644 index 0000000000..a6e48d91ba --- /dev/null +++ b/msa/web-ui/src/main/filters/windows.properties @@ -0,0 +1,2 @@ +pkg.logFolder=${BASE}\\logs +pkg.winWrapperLogFolder=%BASE%\\logs diff --git a/msa/web-ui/src/main/scripts/control/deb/postinst b/msa/web-ui/src/main/scripts/control/deb/postinst new file mode 100644 index 0000000000..0767d3f2c7 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/deb/postinst @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R ${pkg.user}: ${pkg.logFolder} +chown -R ${pkg.user}: ${pkg.installFolder} +update-rc.d ${pkg.name} defaults + diff --git a/msa/web-ui/src/main/scripts/control/deb/postrm b/msa/web-ui/src/main/scripts/control/deb/postrm new file mode 100644 index 0000000000..61865803c3 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/deb/postrm @@ -0,0 +1,3 @@ +#!/bin/sh + +update-rc.d -f ${pkg.name} remove diff --git a/msa/web-ui/src/main/scripts/control/deb/preinst b/msa/web-ui/src/main/scripts/control/deb/preinst new file mode 100644 index 0000000000..d2ebea46d7 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/deb/preinst @@ -0,0 +1,18 @@ +#!/bin/sh + +if ! getent group ${pkg.user} >/dev/null; then + addgroup --system ${pkg.user} +fi + +if ! getent passwd ${pkg.user} >/dev/null; then + adduser --quiet \ + --system \ + --ingroup ${pkg.user} \ + --quiet \ + --disabled-login \ + --disabled-password \ + --home ${pkg.installFolder} \ + --no-create-home \ + -gecos "Thingsboard application" \ + ${pkg.user} +fi diff --git a/msa/web-ui/src/main/scripts/control/deb/prerm b/msa/web-ui/src/main/scripts/control/deb/prerm new file mode 100644 index 0000000000..898d3efd5c --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/deb/prerm @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -e /var/run/${pkg.name}/${pkg.name}.pid ]; then + service ${pkg.name} stop +fi diff --git a/msa/web-ui/src/main/scripts/control/rpm/postinst b/msa/web-ui/src/main/scripts/control/rpm/postinst new file mode 100644 index 0000000000..d8021e2dd9 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/rpm/postinst @@ -0,0 +1,9 @@ +#!/bin/sh + +chown -R ${pkg.user}: ${pkg.logFolder} +chown -R ${pkg.user}: ${pkg.installFolder} + +if [ $1 -eq 1 ] ; then + # Initial installation + systemctl --no-reload enable ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/msa/web-ui/src/main/scripts/control/rpm/postrm b/msa/web-ui/src/main/scripts/control/rpm/postrm new file mode 100644 index 0000000000..8e1f8a2048 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/rpm/postrm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/msa/web-ui/src/main/scripts/control/rpm/preinst b/msa/web-ui/src/main/scripts/control/rpm/preinst new file mode 100644 index 0000000000..db6306e4ac --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/rpm/preinst @@ -0,0 +1,6 @@ +#!/bin/sh + +getent group ${pkg.user} >/dev/null || groupadd -r ${pkg.user} +getent passwd ${pkg.user} >/dev/null || \ +useradd -d ${pkg.installFolder} -g ${pkg.user} -M -r ${pkg.user} -s /sbin/nologin \ +-c "Thingsboard application" diff --git a/msa/web-ui/src/main/scripts/control/rpm/prerm b/msa/web-ui/src/main/scripts/control/rpm/prerm new file mode 100644 index 0000000000..accb487b8e --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/rpm/prerm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || : +fi diff --git a/msa/web-ui/src/main/scripts/control/tb-web-ui.service b/msa/web-ui/src/main/scripts/control/tb-web-ui.service new file mode 100644 index 0000000000..f542dd0f92 --- /dev/null +++ b/msa/web-ui/src/main/scripts/control/tb-web-ui.service @@ -0,0 +1,11 @@ +[Unit] +Description=${pkg.name} +After=syslog.target + +[Service] +User=${pkg.user} +ExecStart=${pkg.installFolder}/init/${pkg.name} +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/msa/web-ui/src/main/scripts/init/tb-web-ui b/msa/web-ui/src/main/scripts/init/tb-web-ui new file mode 100644 index 0000000000..d0d3f95fc9 --- /dev/null +++ b/msa/web-ui/src/main/scripts/init/tb-web-ui @@ -0,0 +1,233 @@ +#!/bin/bash +# + + +### BEGIN INIT INFO +# Provides: tb-web-ui +# Required-Start: $remote_fs $syslog $network +# Required-Stop: $remote_fs $syslog $network +# Default-Start: 2 3 4 5 +# Default-Stop: 0 1 6 +# Short-Description: ${project.name} +# Description: ${project.description} +# chkconfig: 2345 99 01 +### END INIT INFO + +[[ -n "$DEBUG" ]] && set -x + +# Initialize variables that cannot be provided by a .conf file +WORKING_DIR="$(pwd)" +# shellcheck disable=SC2153 + +mainfile=${pkg.installFolder}/bin/${pkg.name} +configfile=${pkg.name}.conf + +# Follow symlinks to find the real script and detect init.d script +cd "$(dirname "$0")" || exit 1 +[[ -z "$initfile" ]] && initfile=$(pwd)/$(basename "$0") +while [[ -L "$initfile" ]]; do + [[ "$initfile" =~ init\.d ]] && init_script=$(basename "$initfile") + initfile=$(readlink "$initfile") + cd "$(dirname "$initfile")" || exit 1 + initfile=$(pwd)/$(basename "$initfile") +done +initfolder="$( (cd "$(dirname "initfile")" && pwd -P) )" +cd "$WORKING_DIR" || exit 1 + +# Initialize CONF_FOLDER location +[[ -z "$CONF_FOLDER" ]] && CONF_FOLDER="${pkg.installFolder}/conf" + +# shellcheck source=/dev/null +[[ -r "${CONF_FOLDER}/${configfile}" ]] && source "${CONF_FOLDER}/${configfile}" + +# Initialize PID/LOG locations if they weren't provided by the config file +[[ -z "$PID_FOLDER" ]] && PID_FOLDER="/var/run" +[[ -z "$LOG_FOLDER" ]] && LOG_FOLDER="${pkg.unixLogFolder}" +! [[ "$PID_FOLDER" == /* ]] && PID_FOLDER="$(dirname "$mainfile")"/"$PID_FOLDER" +! [[ "$LOG_FOLDER" == /* ]] && LOG_FOLDER="$(dirname "$mainfile")"/"$LOG_FOLDER" +! [[ -x "$PID_FOLDER" ]] && PID_FOLDER="/tmp" +! [[ -x "$LOG_FOLDER" ]] && LOG_FOLDER="/tmp" + +# Set up defaults +[[ -z "$MODE" ]] && MODE="auto" # modes are "auto", "service" or "run" +[[ -z "$USE_START_STOP_DAEMON" ]] && USE_START_STOP_DAEMON="true" + +# Create an identity for log/pid files +if [[ -z "$identity" ]]; then + if [[ -n "$init_script" ]]; then + identity="${init_script}" + else + identity=$(basename "${initfile%.*}")_${initfolder//\//} + fi +fi + +# Initialize log file name if not provided by the config file +[[ -z "$LOG_FILENAME" ]] && LOG_FILENAME="${identity}.log" + +# ANSI Colors +echoRed() { echo $'\e[0;31m'"$1"$'\e[0m'; } +echoGreen() { echo $'\e[0;32m'"$1"$'\e[0m'; } +echoYellow() { echo $'\e[0;33m'"$1"$'\e[0m'; } + +# Utility functions +checkPermissions() { + touch "$pid_file" &> /dev/null || { echoRed "Operation not permitted (cannot access pid file)"; return 4; } + touch "$log_file" &> /dev/null || { echoRed "Operation not permitted (cannot access log file)"; return 4; } +} + +isRunning() { + ps -p "$1" &> /dev/null +} + +await_file() { + end=$(date +%s) + let "end+=10" + while [[ ! -s "$1" ]] + do + now=$(date +%s) + if [[ $now -ge $end ]]; then + break + fi + sleep 1 + done +} + +# Determine the script mode +action="run" +if [[ "$MODE" == "auto" && -n "$init_script" ]] || [[ "$MODE" == "service" ]]; then + action="$1" + shift +fi + +# Build the pid and log filenames +if [[ "$identity" == "$init_script" ]] || [[ "$identity" == "$APP_NAME" ]]; then + PID_FOLDER="$PID_FOLDER/${identity}" + pid_subfolder=$PID_FOLDER +fi +pid_file="$PID_FOLDER/${identity}.pid" +log_file="$LOG_FOLDER/$LOG_FILENAME" + +# Determine the user to run as if we are root +# shellcheck disable=SC2012 +[[ $(id -u) == "0" ]] && run_user=$(ls -ld "$mainfile" | awk '{print $3}') + +arguments=($RUN_ARGS "$@") + +# Action functions +start() { + if [[ -f "$pid_file" ]]; then + pid=$(cat "$pid_file") + isRunning "$pid" && { echoYellow "Already running [$pid]"; return 0; } + fi + do_start "$@" +} + +do_start() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + mkdir -p "$PID_FOLDER" &> /dev/null + if [[ -n "$run_user" ]]; then + checkPermissions || return $? + if [[ -z "$pid_subfolder" ]]; then + chown "$run_user" "$pid_subfolder" + fi + chown "$run_user" "$pid_file" + chown "$run_user" "$log_file" + if [ $USE_START_STOP_DAEMON = true ] && type start-stop-daemon > /dev/null 2>&1; then + start-stop-daemon --start --quiet \ + --chuid "$run_user" \ + --name "$identity" \ + --make-pidfile --pidfile "$pid_file" \ + --background --no-close \ + --startas "$mainfile" \ + --chdir "$working_dir" \ + -- "${arguments[@]}" \ + >> "$log_file" 2>&1 + await_file "$pid_file" + else + su -s /bin/sh -c "$mainfile $(printf "\"%s\" " "${arguments[@]}") >> \"$log_file\" 2>&1 & echo \$!" "$run_user" > "$pid_file" + fi + pid=$(cat "$pid_file") + else + checkPermissions || return $? + "$mainfile" "${arguments[@]}" >> "$log_file" 2>&1 & + pid=$! + disown $pid + echo "$pid" > "$pid_file" + fi + [[ -z $pid ]] && { echoRed "Failed to start"; return 1; } + echoGreen "Started [$pid]" +} + +stop() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f $pid_file ]] || { echoYellow "Not running (pidfile not found)"; return 0; } + pid=$(cat "$pid_file") + isRunning "$pid" || { echoYellow "Not running (process ${pid}). Removing stale pid file."; rm -f "$pid_file"; return 0; } + do_stop "$pid" "$pid_file" +} + +do_stop() { + kill -2 "$1" &> /dev/null || { echoRed "Unable to kill process $1"; return 1; } + for i in $(seq 1 60); do + isRunning "$1" || { echoGreen "Stopped [$1]"; rm -f "$2"; return 0; } + [[ $i -eq 30 ]] && kill -9 "$1" &> /dev/null + sleep 1 + done + echoRed "Unable to kill process $1"; + return 1; +} + +restart() { + stop && start +} + +orce_reload() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f $pid_file ]] || { echoRed "Not running (pidfile not found)"; return 7; } + pid=$(cat "$pid_file") + rm -f "$pid_file" + isRunning "$pid" || { echoRed "Not running (process ${pid} not found)"; return 7; } + do_stop "$pid" "$pid_file" + do_start +} + +status() { + working_dir=$(dirname "$mainfile") + pushd "$working_dir" > /dev/null + [[ -f "$pid_file" ]] || { echoRed "Not running"; return 3; } + pid=$(cat "$pid_file") + isRunning "$pid" || { echoRed "Not running (process ${pid} not found)"; return 1; } + echoGreen "Running [$pid]" + return 0 +} + +run() { + pushd "$(dirname "$mainfile")" > /dev/null + "$mainfile" "${arguments[@]}" + result=$? + popd > /dev/null + return "$result" +} + +# Call the appropriate action function +case "$action" in +start) + start "$@"; exit $?;; +stop) + stop "$@"; exit $?;; +restart) + restart "$@"; exit $?;; +force-reload) + force_reload "$@"; exit $?;; +status) + status "$@"; exit $?;; +run) + run "$@"; exit $?;; +*) + echo "Usage: $0 {start|stop|restart|force-reload|status|run}"; exit 1; +esac + +exit 0 diff --git a/msa/web-ui/src/main/scripts/windows/install.bat b/msa/web-ui/src/main/scripts/windows/install.bat new file mode 100644 index 0000000000..4da55425bb --- /dev/null +++ b/msa/web-ui/src/main/scripts/windows/install.bat @@ -0,0 +1,31 @@ +@REM +@REM Copyright © 2016-2018 The Thingsboard Authors +@REM +@REM Licensed under the Apache License, Version 2.0 (the "License"); +@REM you may not use this file except in compliance with the License. +@REM You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM + +@ECHO OFF + +setlocal ENABLEEXTENSIONS + +@ECHO Installing ${pkg.name} ... + +SET BASE=%~dp0 + +%BASE%${pkg.name}.exe install + +@ECHO ${pkg.name} installed successfully! + +GOTO END + +:END diff --git a/msa/web-ui/src/main/scripts/windows/service.xml b/msa/web-ui/src/main/scripts/windows/service.xml new file mode 100644 index 0000000000..e512aad163 --- /dev/null +++ b/msa/web-ui/src/main/scripts/windows/service.xml @@ -0,0 +1,30 @@ + + + ${pkg.name} + ${project.name} + ${project.description} + %BASE%\bin + ${pkg.winWrapperLogFolder} + rotate + + + + + %BASE%\bin\${pkg.name}.exe + diff --git a/msa/web-ui/src/main/scripts/windows/uninstall.bat b/msa/web-ui/src/main/scripts/windows/uninstall.bat new file mode 100644 index 0000000000..7061d2a052 --- /dev/null +++ b/msa/web-ui/src/main/scripts/windows/uninstall.bat @@ -0,0 +1,25 @@ +@REM +@REM Copyright © 2016-2018 The Thingsboard Authors +@REM +@REM Licensed under the Apache License, Version 2.0 (the "License"); +@REM you may not use this file except in compliance with the License. +@REM You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, software +@REM distributed under the License is distributed on an "AS IS" BASIS, +@REM WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@REM See the License for the specific language governing permissions and +@REM limitations under the License. +@REM + +@ECHO OFF + +@ECHO Stopping ${pkg.name} ... +net stop ${pkg.name} + +@ECHO Uninstalling ${pkg.name} ... +%~dp0${pkg.name}.exe uninstall + +@ECHO DONE. \ No newline at end of file diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 8dffbfdb01..80e8a5ddb8 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,12 +19,12 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard org.thingsboard netty-mqtt - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT jar Netty MQTT Client diff --git a/pom.xml b/pom.xml index aa54e72000..00c9c28567 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT pom Thingsboard @@ -50,7 +50,7 @@ 1.5.0 2.5 1.4 - 2.8.8.1 + 2.8.11.1 2.2.6 2.11 2.4.2 @@ -66,8 +66,7 @@ 1.1.0 4.1.22.Final 1.5.0 - 3.6.5 - 0.9.0.0 + 4.8.0 2.19.1 3.0.2 2.6.1 @@ -82,6 +81,8 @@ 5.0.2 0.1.14 + 2.0.0 + 4.1.1 @@ -93,6 +94,7 @@ ui tools application + msa @@ -282,6 +284,9 @@ src/main/scripts/control/** src/main/scripts/windows/** src/main/resources/public/static/rulenode/** + **/*.proto.js + docker/haproxy/** + docker/tb-node/** JAVADOC_STYLE @@ -351,18 +356,23 @@ ${project.version} - org.thingsboard.transport - http + org.thingsboard.common.transport + transport-api ${project.version} - org.thingsboard.transport - coap + org.thingsboard.common.transport + mqtt ${project.version} - org.thingsboard.transport - mqtt + org.thingsboard.common.transport + http + ${project.version} + + + org.thingsboard.common.transport + coap ${project.version} @@ -370,6 +380,11 @@ dao ${project.version} + + org.thingsboard.common + queue + ${project.version} + org.thingsboard tools @@ -414,6 +429,11 @@ spring-boot-starter-data-jpa ${spring-boot.version} + + org.apache.kafka + kafka-clients + ${kafka.version} + org.postgresql postgresql @@ -690,21 +710,6 @@ ${lombok.version} provided - - org.apache.kafka - kafka_2.10 - ${kafka.version} - - - org.slf4j - slf4j-log4j12 - - - log4j - log4j - - - org.eclipse.paho org.eclipse.paho.client.mqttv3 @@ -774,6 +779,11 @@ delight-nashorn-sandbox ${delight-nashorn-sandbox.version} + + com.github.vladimir-bukhtoyarov + bucket4j-core + ${bucket4j.version} + diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index 00e49807b0..15341b09b1 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 09da1cdaba..46849b0241 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcRequest.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcRequest.java index 9a93d1e281..f3d57de602 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcRequest.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/RuleEngineDeviceRpcRequest.java @@ -31,6 +31,8 @@ public final class RuleEngineDeviceRpcRequest { private final DeviceId deviceId; private final int requestId; private final UUID requestUUID; + private final String originHost; + private final int originPort; private final boolean oneway; private final String method; private final String body; diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index e7ef0dd40f..f9d3c64211 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -26,6 +26,7 @@ import org.thingsboard.server.dao.asset.AssetService; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TenantService; @@ -83,6 +84,8 @@ public interface TbContext { RelationService getRelationService(); + EntityViewService getEntityViewService(); + ListeningExecutor getJsExecutor(); ListeningExecutor getMailExecutor(); diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index e52e44bb9b..c22ce784fa 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT rule-engine org.thingsboard.rule-engine @@ -45,8 +45,8 @@ provided - org.thingsboard.common - transport + org.thingsboard.common.transport + transport-api provided @@ -71,6 +71,10 @@ com.google.guava guava + + com.google.code.gson + gson + org.springframework spring-web @@ -78,7 +82,7 @@ org.apache.kafka - kafka_2.10 + kafka-clients com.amazonaws diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java new file mode 100644 index 0000000000..459e35bd21 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/action/TbCopyAttributesToEntityViewNode.java @@ -0,0 +1,155 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.action; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.JsonPrimitive; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; +import org.thingsboard.rule.engine.api.RuleNode; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.rule.engine.api.util.DonAsynchron; +import org.thingsboard.rule.engine.api.util.TbNodeUtils; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.session.SessionMsgType; +import org.thingsboard.server.common.transport.adaptor.JsonConverter; + +import javax.annotation.Nullable; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.thingsboard.rule.engine.api.TbRelationTypes.SUCCESS; + +@Slf4j +@RuleNode( + type = ComponentType.ACTION, + name = "copy to view", + configClazz = EmptyNodeConfiguration.class, + nodeDescription = "Copy attributes from asset/device to entity view and changes message originator to related entity view", + nodeDetails = "Copy attributes from asset/device to related entity view according to entity view configuration. \n " + + "Copy will be done only for attributes that are between start and end dates and according to attribute keys configuration. \n" + + "Changes message originator to related entity view and produces new messages according to count of updated entity views", + uiResources = {"static/rulenode/rulenode-core-config.js"}, + configDirective = "tbNodeEmptyConfig", + icon = "content_copy" +) +public class TbCopyAttributesToEntityViewNode implements TbNode { + + EmptyNodeConfiguration config; + + @Override + public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { + this.config = TbNodeUtils.convert(configuration, EmptyNodeConfiguration.class); + } + + @Override + public void onMsg(TbContext ctx, TbMsg msg) { + if (DataConstants.ATTRIBUTES_UPDATED.equals(msg.getType()) || + DataConstants.ATTRIBUTES_DELETED.equals(msg.getType()) || + SessionMsgType.POST_ATTRIBUTES_REQUEST.name().equals(msg.getType())) { + if (!msg.getMetaData().getData().isEmpty()) { + long now = System.currentTimeMillis(); + String scope = msg.getType().equals(SessionMsgType.POST_ATTRIBUTES_REQUEST.name()) ? + DataConstants.CLIENT_SCOPE : msg.getMetaData().getValue("scope"); + + ListenableFuture> entityViewsFuture = + ctx.getEntityViewService().findEntityViewsByTenantIdAndEntityIdAsync(ctx.getTenantId(), msg.getOriginator()); + + DonAsynchron.withCallback(entityViewsFuture, + entityViews -> { + for (EntityView entityView : entityViews) { + long startTime = entityView.getStartTimeMs(); + long endTime = entityView.getEndTimeMs(); + if ((endTime != 0 && endTime > now && startTime < now) || (endTime == 0 && startTime < now)) { + if (DataConstants.ATTRIBUTES_UPDATED.equals(msg.getType()) || + SessionMsgType.POST_ATTRIBUTES_REQUEST.name().equals(msg.getType())) { + Set attributes = JsonConverter.convertToAttributes(new JsonParser().parse(msg.getData())); + List filteredAttributes = + attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr.getKey(), entityView)).collect(Collectors.toList()); + ctx.getTelemetryService().saveAndNotify(entityView.getId(), scope, filteredAttributes, + new FutureCallback() { + @Override + public void onSuccess(@Nullable Void result) { + transformAndTellNext(ctx, msg, entityView); + } + + @Override + public void onFailure(Throwable t) { + ctx.tellFailure(msg, t); + } + }); + } else if (DataConstants.ATTRIBUTES_DELETED.equals(msg.getType())) { + List attributes = new ArrayList<>(); + for (JsonElement element : new JsonParser().parse(msg.getData()).getAsJsonObject().get("attributes").getAsJsonArray()) { + if (element.isJsonPrimitive()) { + JsonPrimitive value = element.getAsJsonPrimitive(); + if (value.isString()) { + attributes.add(value.getAsString()); + } + } + } + List filteredAttributes = + attributes.stream().filter(attr -> attributeContainsInEntityView(scope, attr, entityView)).collect(Collectors.toList()); + if (filteredAttributes != null && !filteredAttributes.isEmpty()) { + ctx.getAttributesService().removeAll(entityView.getId(), scope, filteredAttributes); + transformAndTellNext(ctx, msg, entityView); + } + } + } + } + }, + t -> ctx.tellFailure(msg, t)); + } else { + ctx.tellFailure(msg, new IllegalArgumentException("Message metadata is empty")); + } + } else { + ctx.tellFailure(msg, new IllegalArgumentException("Unsupported msg type [" + msg.getType() + "]")); + } + } + + private void transformAndTellNext(TbContext ctx, TbMsg msg, EntityView entityView) { + TbMsg updMsg = ctx.transformMsg(msg, msg.getType(), entityView.getId(), msg.getMetaData(), msg.getData()); + ctx.tellNext(updMsg, SUCCESS); + } + + private boolean attributeContainsInEntityView(String scope, String attrKey, EntityView entityView) { + switch (scope) { + case DataConstants.CLIENT_SCOPE: + return entityView.getKeys().getAttributes().getCs().contains(attrKey); + case DataConstants.SERVER_SCOPE: + return entityView.getKeys().getAttributes().getSs().contains(attrKey); + case DataConstants.SHARED_SCOPE: + return entityView.getKeys().getAttributes().getSh().contains(attrKey); + } + return false; + } + + @Override + public void destroy() { + } +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNodeConfiguration.java index c568e3dd94..e3766baf7b 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/debug/TbMsgGeneratorNodeConfiguration.java @@ -37,7 +37,7 @@ public class TbMsgGeneratorNodeConfiguration implements NodeConfiguration etc. via corresponding chain, otherwise Other chain is used.", uiResources = {"static/rulenode/rulenode-core-config.js"}, @@ -75,6 +75,10 @@ public class TbMsgTypeSwitchNode implements TbNode { relationType = "Attributes Updated"; } else if (msg.getType().equals(DataConstants.ATTRIBUTES_DELETED)) { relationType = "Attributes Deleted"; + } else if (msg.getType().equals(DataConstants.ALARM_ACK)) { + relationType = "Alarm Acknowledged"; + } else if (msg.getType().equals(DataConstants.ALARM_CLEAR)) { + relationType = "Alarm Cleared"; } else if (msg.getType().equals(DataConstants.RPC_CALL_FROM_SERVER_TO_DEVICE)) { relationType = "RPC Request to Device"; } else { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java index 5d7e1241f7..8c95b887d2 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rpc/TbSendRPCRequestNode.java @@ -86,6 +86,10 @@ public class TbSendRPCRequestNode implements TbNode { tmp = msg.getMetaData().getValue("requestUUID"); UUID requestUUID = !StringUtils.isEmpty(tmp) ? UUID.fromString(tmp) : UUIDs.timeBased(); + tmp = msg.getMetaData().getValue("originHost"); + String originHost = !StringUtils.isEmpty(tmp) ? tmp : null; + tmp = msg.getMetaData().getValue("originPort"); + int originPort = !StringUtils.isEmpty(tmp) ? Integer.parseInt(tmp) : 0; tmp = msg.getMetaData().getValue("expirationTime"); long expirationTime = !StringUtils.isEmpty(tmp) ? Long.parseLong(tmp) : (System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(config.getTimeoutInSeconds())); @@ -105,6 +109,8 @@ public class TbSendRPCRequestNode implements TbNode { .deviceId(new DeviceId(msg.getOriginator().getId())) .requestId(requestId) .requestUUID(requestUUID) + .originHost(originHost) + .originPort(originPort) .expirationTime(expirationTime) .restApiCall(restApiCall) .build(); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java index 5cdb04ed5b..12dbc6dd89 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgAttributesNode.java @@ -63,7 +63,7 @@ public class TbMsgAttributesNode implements TbNode { } String src = msg.getData(); - Set attributes = JsonConverter.convertToAttributes(new JsonParser().parse(src)).getAttributes(); + Set attributes = JsonConverter.convertToAttributes(new JsonParser().parse(src)); ctx.getTelemetryService().saveAndNotify(msg.getOriginator(), config.getScope(), new ArrayList<>(attributes), new TelemetryNodeCallback(ctx, msg)); if (msg.getOriginator().getEntityType() == EntityType.DEVICE && DataConstants.SHARED_SCOPE.equals(config.getScope())) { ctx.getTelemetryService().onSharedAttributesUpdate(ctx.getTenantId(), new DeviceId(msg.getOriginator().getId()), attributes); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java index efdf4af3f2..4cb9999969 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/telemetry/TbMsgTimeseriesNode.java @@ -29,7 +29,6 @@ import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.common.msg.core.TelemetryUploadRequest; import org.thingsboard.server.common.msg.session.SessionMsgType; import org.thingsboard.server.common.transport.adaptor.JsonConverter; @@ -68,13 +67,13 @@ public class TbMsgTimeseriesNode implements TbNode { if (!StringUtils.isEmpty(tsStr)) { try { ts = Long.parseLong(tsStr); - } catch (NumberFormatException e) {} + } catch (NumberFormatException e) { + } } else { ts = System.currentTimeMillis(); } String src = msg.getData(); - TelemetryUploadRequest telemetryUploadRequest = JsonConverter.convertToTelemetry(new JsonParser().parse(src), ts); - Map> tsKvMap = telemetryUploadRequest.getData(); + Map> tsKvMap = JsonConverter.convertToTelemetry(new JsonParser().parse(src), ts); if (tsKvMap == null) { ctx.tellFailure(msg, new IllegalArgumentException("Msg body is empty: " + src)); return; diff --git a/tools/pom.xml b/tools/pom.xml index a3863300c1..f61634f3f0 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard org.thingsboard diff --git a/transport/coap/build.gradle b/transport/coap/build.gradle new file mode 100644 index 0000000000..6d54cb4789 --- /dev/null +++ b/transport/coap/build.gradle @@ -0,0 +1,140 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +buildscript { + ext { + osPackageVersion = "3.8.0" + } + repositories { + jcenter() + } + dependencies { + classpath("com.netflix.nebula:gradle-ospackage-plugin:${osPackageVersion}") + } +} + +apply plugin: "nebula.ospackage" + +buildDir = projectBuildDir +version = projectVersion +distsDirName = "./" + +// OS Package plugin configuration +ospackage { + packageName = pkgName + version = "${project.version}" + release = 1 + os = LINUX + type = BINARY + + into pkgInstallFolder + + user pkgName + permissionGroup pkgName + + // Copy the actual .jar file + from(mainJar) { + // Strip the version from the jar filename + rename { String fileName -> + "${pkgName}.jar" + } + fileMode 0500 + into "bin" + } + + // Copy the config files + from("target/conf") { + exclude "${pkgName}.conf" + fileType CONFIG | NOREPLACE + fileMode 0754 + into "conf" + } + +} + +// Configure our RPM build task +buildRpm { + + arch = NOARCH + + version = projectVersion.replace('-', '') + archiveName = "${pkgName}.rpm" + + requires("java-1.8.0") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'rpm']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + preInstall file("${buildDir}/control/rpm/preinst") + postInstall file("${buildDir}/control/rpm/postinst") + preUninstall file("${buildDir}/control/rpm/prerm") + postUninstall file("${buildDir}/control/rpm/postrm") + + user pkgName + permissionGroup pkgName + + // Copy the system unit files + from("${buildDir}/control/${pkgName}.service") { + addParentDirs = false + fileMode 0644 + into "/usr/lib/systemd/system" + } + + directory(pkgLogFolder, 0755) + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} + +// Same as the buildRpm task +buildDeb { + + arch = "all" + + archiveName = "${pkgName}.deb" + + requires("openjdk-8-jre").or("java8-runtime").or("oracle-java8-installer").or("openjdk-8-jre-headless") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'deb']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + configurationFile("${pkgInstallFolder}/conf/${pkgName}.conf") + configurationFile("${pkgInstallFolder}/conf/${pkgName}.yml") + configurationFile("${pkgInstallFolder}/conf/logback.xml") + + preInstall file("${buildDir}/control/deb/preinst") + postInstall file("${buildDir}/control/deb/postinst") + preUninstall file("${buildDir}/control/deb/prerm") + postUninstall file("${buildDir}/control/deb/postrm") + + user pkgName + permissionGroup pkgName + + directory(pkgLogFolder, 0755) + link("/etc/init.d/${pkgName}", "${pkgInstallFolder}/bin/${pkgName}.jar") + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index 2360a7518c..a5e8107ab6 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,49 +20,44 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT transport org.thingsboard.transport coap jar - Thingsboard COAP Transport + Thingsboard CoAP Transport Service https://thingsboard.io UTF-8 ${basedir}/../.. + tb-coap-transport + /var/log/${pkg.name} + /usr/share/${pkg.name} + ${project.build.directory}/windows - org.thingsboard.common - transport - - - org.eclipse.californium - californium-core - - - org.springframework - spring-context + org.thingsboard.common.transport + coap - org.slf4j - slf4j-api - - - org.slf4j - log4j-over-slf4j + org.thingsboard.common + queue - ch.qos.logback - logback-core + org.springframework.boot + spring-boot-starter-web - ch.qos.logback - logback-classic + com.sun.winsw + winsw + bin + exe + provided org.springframework.boot @@ -81,4 +76,266 @@ + + ${pkg.name}-${project.version} + + + ${project.basedir}/src/main/resources + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/resources + + logback.xml + + false + + + + + + copy-service-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/conf + true + + + + src/main/filters/unix.properties + + + + + copy-win-conf + process-resources + + copy-resources + + + ${pkg.win.dist}/conf + + + src/main/resources + + logback.xml + + false + + + src/main/conf + + tb-coap-transport.conf + + true + + + + src/main/filters/windows.properties + + + + + copy-control + process-resources + + copy-resources + + + ${project.build.directory}/control + + + src/main/scripts/control + true + + + + src/main/filters/unix.properties + + + + + copy-windows-control + process-resources + + copy-resources + + + ${pkg.win.dist} + + + src/main/scripts/windows + true + + + + src/main/filters/windows.properties + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-winsw-service + package + + copy + + + + + com.sun.winsw + winsw + bin + exe + service.exe + + + ${pkg.win.dist} + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + **/logback.xml + + + + ThingsBoard CoAP Transport Service + ${project.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.thingsboard.server.coap.ThingsboardCoapTransportApplication + boot + ZIP + true + true + + ${pkg.installFolder}/conf + ${pkg.unixLogFolder} + ${pkg.name}.out + ${pkg.name} + + + + + + repackage + + + + + + org.fortasoft + gradle-maven-plugin + + + build + buildDeb + buildRpm + + + -PprojectBuildDir=${project.build.directory} + -PprojectVersion=${project.version} + -PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + -PpkgName=${pkg.name} + -PpkgInstallFolder=${pkg.installFolder} + -PpkgLogFolder=${pkg.unixLogFolder} + + + + + package + + invoke + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + ${pkg.name} + + src/main/assembly/windows.xml + + + + + assembly + package + + single + + + + + + org.apache.maven.plugins + maven-install-plugin + + ${project.build.directory}/${pkg.name}.deb + ${project.artifactId} + ${project.groupId} + ${project.version} + deb + deb + + + + install-deb + package + + install-file + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + diff --git a/transport/coap/src/main/assembly/windows.xml b/transport/coap/src/main/assembly/windows.xml new file mode 100644 index 0000000000..82da34ed13 --- /dev/null +++ b/transport/coap/src/main/assembly/windows.xml @@ -0,0 +1,71 @@ + + + windows + + + zip + + + + + + ${pkg.win.dist} + logs + + */** + + + + ${pkg.win.dist}/conf + conf + windows + + + + + + ${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + lib + ${pkg.name}.jar + + + ${pkg.win.dist}/service.exe + + ${pkg.name}.exe + + + ${pkg.win.dist}/service.xml + + ${pkg.name}.xml + windows + + + ${pkg.win.dist}/install.bat + + windows + + + ${pkg.win.dist}/uninstall.bat + + windows + + + diff --git a/transport/coap/src/main/conf/logback.xml b/transport/coap/src/main/conf/logback.xml new file mode 100644 index 0000000000..f36469d5f2 --- /dev/null +++ b/transport/coap/src/main/conf/logback.xml @@ -0,0 +1,43 @@ + + + + + + + ${pkg.logFolder}/${pkg.name}.log + + ${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/transport/coap/src/main/conf/tb-coap-transport.conf b/transport/coap/src/main/conf/tb-coap-transport.conf new file mode 100644 index 0000000000..0afa91c18e --- /dev/null +++ b/transport/coap/src/main/conf/tb-coap-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -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" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/transport/coap/src/main/filters/unix.properties b/transport/coap/src/main/filters/unix.properties new file mode 100644 index 0000000000..8967278673 --- /dev/null +++ b/transport/coap/src/main/filters/unix.properties @@ -0,0 +1 @@ +pkg.logFolder=${pkg.unixLogFolder} \ No newline at end of file diff --git a/transport/coap/src/main/filters/windows.properties b/transport/coap/src/main/filters/windows.properties new file mode 100644 index 0000000000..a6e48d91ba --- /dev/null +++ b/transport/coap/src/main/filters/windows.properties @@ -0,0 +1,2 @@ +pkg.logFolder=${BASE}\\logs +pkg.winWrapperLogFolder=%BASE%\\logs diff --git a/transport/coap/src/main/java/org/thingsboard/server/coap/ThingsboardCoapTransportApplication.java b/transport/coap/src/main/java/org/thingsboard/server/coap/ThingsboardCoapTransportApplication.java new file mode 100644 index 0000000000..1ad86dd166 --- /dev/null +++ b/transport/coap/src/main/java/org/thingsboard/server/coap/ThingsboardCoapTransportApplication.java @@ -0,0 +1,48 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.coap; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.Arrays; + +@SpringBootConfiguration +@EnableAsync +@EnableScheduling +@ComponentScan({"org.thingsboard.server.coap", "org.thingsboard.server.common", "org.thingsboard.server.transport.coap", "org.thingsboard.server.kafka"}) +public class ThingsboardCoapTransportApplication { + + private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; + private static final String DEFAULT_SPRING_CONFIG_PARAM = SPRING_CONFIG_NAME_KEY + "=" + "tb-coap-transport"; + + public static void main(String[] args) { + SpringApplication.run(ThingsboardCoapTransportApplication.class, updateArguments(args)); + } + + private static String[] updateArguments(String[] args) { + if (Arrays.stream(args).noneMatch(arg -> arg.startsWith(SPRING_CONFIG_NAME_KEY))) { + String[] modifiedArgs = new String[args.length + 1]; + System.arraycopy(args, 0, modifiedArgs, 0, args.length); + modifiedArgs[args.length] = DEFAULT_SPRING_CONFIG_PARAM; + return modifiedArgs; + } + return args; + } +} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java deleted file mode 100644 index bac68ffc58..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java +++ /dev/null @@ -1,237 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap; - -import java.lang.reflect.Field; -import java.util.List; -import java.util.Optional; - -import lombok.extern.slf4j.Slf4j; -import org.eclipse.californium.core.CoapResource; -import org.eclipse.californium.core.coap.CoAP.ResponseCode; -import org.eclipse.californium.core.coap.Request; -import org.eclipse.californium.core.network.Exchange; -import org.eclipse.californium.core.network.ExchangeObserver; -import org.eclipse.californium.core.server.resources.CoapExchange; -import org.eclipse.californium.core.server.resources.Resource; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; -import org.thingsboard.server.common.data.security.DeviceTokenCredentials; -import org.thingsboard.server.common.msg.session.*; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.QuotaService; -import org.thingsboard.server.transport.coap.adaptors.CoapTransportAdaptor; -import org.thingsboard.server.transport.coap.session.CoapExchangeObserverProxy; -import org.thingsboard.server.transport.coap.session.CoapSessionCtx; -import org.springframework.util.ReflectionUtils; - -@Slf4j -public class CoapTransportResource extends CoapResource { - // coap://localhost:port/api/v1/DEVICE_TOKEN/[attributes|telemetry|rpc[/requestId]] - private static final int ACCESS_TOKEN_POSITION = 3; - private static final int FEATURE_TYPE_POSITION = 4; - private static final int REQUEST_ID_POSITION = 5; - - private final CoapTransportAdaptor adaptor; - private final SessionMsgProcessor processor; - private final DeviceAuthService authService; - private final QuotaService quotaService; - private final Field observerField; - private final long timeout; - - public CoapTransportResource(SessionMsgProcessor processor, DeviceAuthService authService, CoapTransportAdaptor adaptor, String name, - long timeout, QuotaService quotaService) { - super(name); - this.processor = processor; - this.authService = authService; - this.quotaService = quotaService; - this.adaptor = adaptor; - this.timeout = timeout; - // This is important to turn off existing observable logic in - // CoapResource. We will have our own observe monitoring due to 1:1 - // observe relationship. - this.setObservable(false); - observerField = ReflectionUtils.findField(Exchange.class, "observer"); - observerField.setAccessible(true); - } - - @Override - public void handleGET(CoapExchange exchange) { - if(quotaService.isQuotaExceeded(exchange.getSourceAddress().getHostAddress())) { - log.warn("COAP Quota exceeded for [{}:{}] . Disconnect", exchange.getSourceAddress().getHostAddress(), exchange.getSourcePort()); - exchange.respond(ResponseCode.BAD_REQUEST); - return; - } - - Optional featureType = getFeatureType(exchange.advanced().getRequest()); - if (!featureType.isPresent()) { - log.trace("Missing feature type parameter"); - exchange.respond(ResponseCode.BAD_REQUEST); - } else if (featureType.get() == FeatureType.TELEMETRY) { - log.trace("Can't fetch/subscribe to timeseries updates"); - exchange.respond(ResponseCode.BAD_REQUEST); - } else if (exchange.getRequestOptions().hasObserve()) { - processExchangeGetRequest(exchange, featureType.get()); - } else if (featureType.get() == FeatureType.ATTRIBUTES) { - processRequest(exchange, SessionMsgType.GET_ATTRIBUTES_REQUEST); - } else { - log.trace("Invalid feature type parameter"); - exchange.respond(ResponseCode.BAD_REQUEST); - } - } - - private void processExchangeGetRequest(CoapExchange exchange, FeatureType featureType) { - boolean unsubscribe = exchange.getRequestOptions().getObserve() == 1; - SessionMsgType sessionMsgType; - if (featureType == FeatureType.RPC) { - sessionMsgType = unsubscribe ? SessionMsgType.UNSUBSCRIBE_RPC_COMMANDS_REQUEST : SessionMsgType.SUBSCRIBE_RPC_COMMANDS_REQUEST; - } else { - sessionMsgType = unsubscribe ? SessionMsgType.UNSUBSCRIBE_ATTRIBUTES_REQUEST : SessionMsgType.SUBSCRIBE_ATTRIBUTES_REQUEST; - } - Optional sessionId = processRequest(exchange, sessionMsgType); - if (sessionId.isPresent()) { - if (exchange.getRequestOptions().getObserve() == 1) { - exchange.respond(ResponseCode.VALID); - } - } - } - - @Override - public void handlePOST(CoapExchange exchange) { - Optional featureType = getFeatureType(exchange.advanced().getRequest()); - if (!featureType.isPresent()) { - log.trace("Missing feature type parameter"); - exchange.respond(ResponseCode.BAD_REQUEST); - } else { - switch (featureType.get()) { - case ATTRIBUTES: - processRequest(exchange, SessionMsgType.POST_ATTRIBUTES_REQUEST); - break; - case TELEMETRY: - processRequest(exchange, SessionMsgType.POST_TELEMETRY_REQUEST); - break; - case RPC: - Optional requestId = getRequestId(exchange.advanced().getRequest()); - if (requestId.isPresent()) { - processRequest(exchange, SessionMsgType.TO_DEVICE_RPC_RESPONSE); - } else { - processRequest(exchange, SessionMsgType.TO_SERVER_RPC_REQUEST); - } - break; - } - } - } - - private Optional processRequest(CoapExchange exchange, SessionMsgType type) { - log.trace("Processing {}", exchange.advanced().getRequest()); - exchange.accept(); - Exchange advanced = exchange.advanced(); - Request request = advanced.getRequest(); - - Optional credentials = decodeCredentials(request); - if (!credentials.isPresent()) { - exchange.respond(ResponseCode.BAD_REQUEST); - return Optional.empty(); - } - - CoapSessionCtx ctx = new CoapSessionCtx(exchange, adaptor, processor, authService, timeout); - - if (!ctx.login(credentials.get())) { - exchange.respond(ResponseCode.UNAUTHORIZED); - return Optional.empty(); - } - - AdaptorToSessionActorMsg msg; - try { - switch (type) { - case GET_ATTRIBUTES_REQUEST: - case POST_ATTRIBUTES_REQUEST: - case POST_TELEMETRY_REQUEST: - case TO_DEVICE_RPC_RESPONSE: - case TO_SERVER_RPC_REQUEST: - ctx.setSessionType(SessionType.SYNC); - msg = adaptor.convertToActorMsg(ctx, type, request); - break; - case SUBSCRIBE_ATTRIBUTES_REQUEST: - case SUBSCRIBE_RPC_COMMANDS_REQUEST: - ExchangeObserver systemObserver = (ExchangeObserver) observerField.get(advanced); - advanced.setObserver(new CoapExchangeObserverProxy(systemObserver, ctx)); - ctx.setSessionType(SessionType.ASYNC); - msg = adaptor.convertToActorMsg(ctx, type, request); - break; - case UNSUBSCRIBE_ATTRIBUTES_REQUEST: - case UNSUBSCRIBE_RPC_COMMANDS_REQUEST: - ctx.setSessionType(SessionType.ASYNC); - msg = adaptor.convertToActorMsg(ctx, type, request); - break; - default: - log.trace("[{}] Unsupported msg type: {}", ctx.getSessionId(), type); - throw new IllegalArgumentException("Unsupported msg type: " + type); - } - log.trace("Processing msg: {}", msg); - processor.process(new BasicTransportToDeviceSessionActorMsg(ctx.getDevice(), msg)); - } catch (AdaptorException e) { - log.debug("Failed to decode payload {}", e); - exchange.respond(ResponseCode.BAD_REQUEST, e.getMessage()); - return Optional.empty(); - } catch (IllegalArgumentException | IllegalAccessException e) { - log.debug("Failed to process payload {}", e); - exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR, e.getMessage()); - } - return Optional.of(ctx.getSessionId()); - } - - private Optional decodeCredentials(Request request) { - List uriPath = request.getOptions().getUriPath(); - DeviceCredentialsFilter credentials = null; - if (uriPath.size() >= ACCESS_TOKEN_POSITION) { - credentials = new DeviceTokenCredentials(uriPath.get(ACCESS_TOKEN_POSITION - 1)); - } - return Optional.ofNullable(credentials); - } - - private Optional getFeatureType(Request request) { - List uriPath = request.getOptions().getUriPath(); - try { - if (uriPath.size() >= FEATURE_TYPE_POSITION) { - return Optional.of(FeatureType.valueOf(uriPath.get(FEATURE_TYPE_POSITION - 1).toUpperCase())); - } - } catch (RuntimeException e) { - log.warn("Failed to decode feature type: {}", uriPath); - } - return Optional.empty(); - } - - public static Optional getRequestId(Request request) { - List uriPath = request.getOptions().getUriPath(); - try { - if (uriPath.size() >= REQUEST_ID_POSITION) { - return Optional.of(Integer.valueOf(uriPath.get(REQUEST_ID_POSITION - 1))); - } - } catch (RuntimeException e) { - log.warn("Failed to decode feature type: {}", uriPath); - } - return Optional.empty(); - } - - @Override - public Resource getChild(String name) { - return this; - } - -} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java deleted file mode 100644 index d9a04b163a..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap.adaptors; - -import org.eclipse.californium.core.coap.Request; -import org.eclipse.californium.core.coap.Response; -import org.thingsboard.server.common.transport.TransportAdaptor; -import org.thingsboard.server.transport.coap.session.CoapSessionCtx; - -public interface CoapTransportAdaptor extends TransportAdaptor { - -} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java deleted file mode 100644 index 1c9631129f..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java +++ /dev/null @@ -1,265 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap.adaptors; - -import java.util.*; - -import com.google.gson.JsonElement; -import lombok.extern.slf4j.Slf4j; -import org.eclipse.californium.core.coap.CoAP.ResponseCode; -import org.eclipse.californium.core.coap.Request; -import org.eclipse.californium.core.coap.Response; -import org.springframework.util.StringUtils; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.msg.session.SessionMsgType; -import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.SessionContext; -import org.thingsboard.server.common.msg.session.ToDeviceMsg; -import org.thingsboard.server.common.msg.session.ex.ProcessingTimeoutException; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.springframework.stereotype.Component; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.JsonSyntaxException; -import org.thingsboard.server.transport.coap.CoapTransportResource; -import org.thingsboard.server.transport.coap.session.CoapSessionCtx; - -@Component("JsonCoapAdaptor") -@Slf4j -public class JsonCoapAdaptor implements CoapTransportAdaptor { - - @Override - public AdaptorToSessionActorMsg convertToActorMsg(CoapSessionCtx ctx, SessionMsgType type, Request inbound) throws AdaptorException { - FromDeviceMsg msg = null; - switch (type) { - case POST_TELEMETRY_REQUEST: - msg = convertToTelemetryUploadRequest(ctx, inbound); - break; - case POST_ATTRIBUTES_REQUEST: - msg = convertToUpdateAttributesRequest(ctx, inbound); - break; - case GET_ATTRIBUTES_REQUEST: - msg = convertToGetAttributesRequest(ctx, inbound); - break; - case SUBSCRIBE_RPC_COMMANDS_REQUEST: - msg = new RpcSubscribeMsg(); - break; - case UNSUBSCRIBE_RPC_COMMANDS_REQUEST: - msg = new RpcUnsubscribeMsg(); - break; - case TO_DEVICE_RPC_RESPONSE: - msg = convertToDeviceRpcResponse(ctx, inbound); - break; - case TO_SERVER_RPC_REQUEST: - msg = convertToServerRpcRequest(ctx, inbound); - break; - case SUBSCRIBE_ATTRIBUTES_REQUEST: - msg = new AttributesSubscribeMsg(); - break; - case UNSUBSCRIBE_ATTRIBUTES_REQUEST: - msg = new AttributesUnsubscribeMsg(); - break; - default: - log.warn("[{}] Unsupported msg type: {}!", ctx.getSessionId(), type); - throw new AdaptorException(new IllegalArgumentException("Unsupported msg type: " + type + "!")); - } - return new BasicAdaptorToSessionActorMsg(ctx, msg); - } - - private FromDeviceMsg convertToDeviceRpcResponse(CoapSessionCtx ctx, Request inbound) throws AdaptorException { - Optional requestId = CoapTransportResource.getRequestId(inbound); - String payload = validatePayload(ctx, inbound); - JsonObject response = new JsonParser().parse(payload).getAsJsonObject(); - return new ToDeviceRpcResponseMsg( - requestId.orElseThrow(() -> new AdaptorException("Request id is missing!")), - response.get("response").toString()); - } - - private FromDeviceMsg convertToServerRpcRequest(CoapSessionCtx ctx, Request inbound) throws AdaptorException { - - String payload = validatePayload(ctx, inbound); - - return JsonConverter.convertToServerRpcRequest(new JsonParser().parse(payload), 0); - } - - @Override - public Optional convertToAdaptorMsg(CoapSessionCtx ctx, SessionActorToAdaptorMsg source) throws AdaptorException { - ToDeviceMsg msg = source.getMsg(); - switch (msg.getSessionMsgType()) { - case STATUS_CODE_RESPONSE: - case TO_DEVICE_RPC_RESPONSE_ACK: - return Optional.of(convertStatusCodeResponse((StatusCodeResponse) msg)); - case GET_ATTRIBUTES_RESPONSE: - return Optional.of(convertGetAttributesResponse((GetAttributesResponse) msg)); - case ATTRIBUTES_UPDATE_NOTIFICATION: - return Optional.of(convertNotificationResponse(ctx, (AttributesUpdateNotification) msg)); - case TO_DEVICE_RPC_REQUEST: - return Optional.of(convertToDeviceRpcRequest(ctx, (ToDeviceRpcRequestMsg) msg)); - case TO_SERVER_RPC_RESPONSE: - return Optional.of(convertToServerRpcResponse(ctx, (ToServerRpcResponseMsg) msg)); - case RULE_ENGINE_ERROR: - return Optional.of(convertToRuleEngineErrorResponse(ctx, (RuleEngineErrorMsg) msg)); - default: - log.warn("[{}] Unsupported msg type: {}!", source.getSessionId(), msg.getSessionMsgType()); - throw new AdaptorException(new IllegalArgumentException("Unsupported msg type: " + msg.getSessionMsgType() + "!")); - } - } - - private Response convertToRuleEngineErrorResponse(CoapSessionCtx ctx, RuleEngineErrorMsg msg) { - ResponseCode status = ResponseCode.INTERNAL_SERVER_ERROR; - switch (msg.getError()) { - case QUEUE_PUT_TIMEOUT: - status = ResponseCode.GATEWAY_TIMEOUT; - break; - default: - if (msg.getInSessionMsgType() == SessionMsgType.TO_SERVER_RPC_REQUEST) { - status = ResponseCode.BAD_REQUEST; - } - break; - } - Response response = new Response(status); - response.setPayload(JsonConverter.toErrorJson(msg.getErrorMsg()).toString()); - return response; - } - - private Response convertNotificationResponse(CoapSessionCtx ctx, AttributesUpdateNotification msg) { - return getObserveNotification(ctx, JsonConverter.toJson(msg.getData(), false)); - } - - private Response convertToDeviceRpcRequest(CoapSessionCtx ctx, ToDeviceRpcRequestMsg msg) { - return getObserveNotification(ctx, JsonConverter.toJson(msg, true)); - } - - private Response getObserveNotification(CoapSessionCtx ctx, JsonObject json) { - Response response = new Response(ResponseCode.CONTENT); - response.getOptions().setObserve(ctx.nextSeqNumber()); - response.setPayload(json.toString()); - return response; - } - - private AttributesUpdateRequest convertToUpdateAttributesRequest(SessionContext ctx, Request inbound) throws AdaptorException { - String payload = validatePayload(ctx, inbound); - try { - return JsonConverter.convertToAttributes(new JsonParser().parse(payload)); - } catch (IllegalStateException | JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - - private FromDeviceMsg convertToGetAttributesRequest(SessionContext ctx, Request inbound) throws AdaptorException { - List queryElements = inbound.getOptions().getUriQuery(); - if (queryElements != null && queryElements.size() > 0) { - Set clientKeys = toKeys(ctx, queryElements, "clientKeys"); - Set sharedKeys = toKeys(ctx, queryElements, "sharedKeys"); - return new BasicGetAttributesRequest(0, clientKeys, sharedKeys); - } else { - return new BasicGetAttributesRequest(0); - } - } - - private Set toKeys(SessionContext ctx, List queryElements, String attributeName) throws AdaptorException { - String keys = null; - for (String queryElement : queryElements) { - String[] queryItem = queryElement.split("="); - if (queryItem.length == 2 && queryItem[0].equals(attributeName)) { - keys = queryItem[1]; - } - } - if (keys != null && !StringUtils.isEmpty(keys)) { - return new HashSet<>(Arrays.asList(keys.split(","))); - } else { - return null; - } - } - - private TelemetryUploadRequest convertToTelemetryUploadRequest(SessionContext ctx, Request inbound) throws AdaptorException { - String payload = validatePayload(ctx, inbound); - try { - return JsonConverter.convertToTelemetry(new JsonParser().parse(payload)); - } catch (IllegalStateException | JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - - private Response convertStatusCodeResponse(StatusCodeResponse msg) { - if (msg.isSuccess()) { - Optional code = msg.getData(); - if (code.isPresent() && code.get() == 200) { - return new Response(ResponseCode.VALID); - } else { - return new Response(ResponseCode.CREATED); - } - } else { - return convertError(msg.getError()); - } - } - - private String validatePayload(SessionContext ctx, Request inbound) throws AdaptorException { - String payload = inbound.getPayloadString(); - if (payload == null) { - log.warn("[{}] Payload is empty!", ctx.getSessionId()); - throw new AdaptorException(new IllegalArgumentException("Payload is empty!")); - } - return payload; - } - - private Response convertToServerRpcResponse(SessionContext ctx, ToServerRpcResponseMsg msg) { - if (msg.isSuccess()) { - Response response = new Response(ResponseCode.CONTENT); - JsonElement result = JsonConverter.toJson(msg); - response.setPayload(result.toString()); - return response; - } else { - return convertError(Optional.of(new RuntimeException("Server RPC response is empty!"))); - } - } - - private Response convertGetAttributesResponse(GetAttributesResponse msg) { - if (msg.isSuccess()) { - Optional payload = msg.getData(); - if (!payload.isPresent() || (payload.get().getClientAttributes().isEmpty() && payload.get().getSharedAttributes().isEmpty())) { - return new Response(ResponseCode.NOT_FOUND); - } else { - Response response = new Response(ResponseCode.CONTENT); - JsonObject result = JsonConverter.toJson(payload.get(), false); - response.setPayload(result.toString()); - return response; - } - } else { - return convertError(msg.getError()); - } - } - - private Response convertError(Optional exception) { - if (exception.isPresent()) { - log.warn("Converting exception: {}", exception.get().getMessage(), exception.get()); - if (exception.get() instanceof ProcessingTimeoutException) { - return new Response(ResponseCode.SERVICE_UNAVAILABLE); - } else { - return new Response(ResponseCode.INTERNAL_SERVER_ERROR); - } - } else { - return new Response(ResponseCode.INTERNAL_SERVER_ERROR); - } - } - -} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapExchangeObserverProxy.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapExchangeObserverProxy.java deleted file mode 100644 index 0c264817d5..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapExchangeObserverProxy.java +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap.session; - -import org.eclipse.californium.core.network.Exchange; -import org.eclipse.californium.core.network.ExchangeObserver; - -public class CoapExchangeObserverProxy implements ExchangeObserver { - - private final ExchangeObserver proxy; - private final CoapSessionCtx ctx; - - public CoapExchangeObserverProxy(ExchangeObserver proxy, CoapSessionCtx ctx) { - super(); - this.proxy = proxy; - this.ctx = ctx; - } - - @Override - public void completed(Exchange exchange) { - proxy.completed(exchange); - ctx.close(); - } - -} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java deleted file mode 100644 index 7a703b2be3..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionCtx.java +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap.session; - -import lombok.extern.slf4j.Slf4j; -import org.eclipse.californium.core.coap.CoAP.ResponseCode; -import org.eclipse.californium.core.coap.Request; -import org.eclipse.californium.core.coap.Response; -import org.eclipse.californium.core.server.resources.CoapExchange; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import org.thingsboard.server.common.msg.session.ex.SessionAuthException; -import org.thingsboard.server.common.msg.session.ex.SessionException; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; -import org.thingsboard.server.transport.coap.adaptors.CoapTransportAdaptor; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.concurrent.atomic.AtomicInteger; - -@Slf4j -public class CoapSessionCtx extends DeviceAwareSessionContext { - - private final SessionId sessionId; - private final CoapExchange exchange; - private final CoapTransportAdaptor adaptor; - private final String token; - private final long timeout; - private SessionType sessionType; - private final AtomicInteger seqNumber = new AtomicInteger(2); - - public CoapSessionCtx(CoapExchange exchange, CoapTransportAdaptor adaptor, SessionMsgProcessor processor, DeviceAuthService authService, long timeout) { - super(processor, authService); - Request request = exchange.advanced().getRequest(); - this.token = request.getTokenString(); - this.sessionId = new CoapSessionId(request.getSource().getHostAddress(), request.getSourcePort(), this.token); - this.exchange = exchange; - this.adaptor = adaptor; - this.timeout = timeout; - } - - - @Override - public void onMsg(SessionActorToAdaptorMsg msg) throws SessionException { - try { - adaptor.convertToAdaptorMsg(this, msg).ifPresent(this::pushToNetwork); - } catch (AdaptorException e) { - logAndWrap(e); - } - } - - private void pushToNetwork(Response response) { - exchange.respond(response); - } - - private void logAndWrap(AdaptorException e) throws SessionException { - log.warn("Failed to convert msg: {}", e.getMessage(), e); - throw new SessionException(e); - } - - @Override - public void onMsg(SessionCtrlMsg msg) throws SessionException { - log.debug("[{}] onCtrl: {}", sessionId, msg); - if (msg instanceof SessionCloseMsg) { - onSessionClose((SessionCloseMsg) msg); - } - } - - private void onSessionClose(SessionCloseMsg msg) { - if (msg.isTimeout()) { - exchange.respond(ResponseCode.SERVICE_UNAVAILABLE); - } else if (msg.isCredentialsRevoked()) { - exchange.respond(ResponseCode.UNAUTHORIZED); - } else { - exchange.respond(ResponseCode.INTERNAL_SERVER_ERROR); - } - } - - @Override - public SessionId getSessionId() { - return sessionId; - } - - @Override - public String toString() { - return "CoapSessionCtx [sessionId=" + sessionId + "]"; - } - - @Override - public boolean isClosed() { - return exchange.advanced().isComplete() || exchange.advanced().isTimedOut(); - } - - public void close() { - log.info("[{}] Closing processing context. Timeout: {}", sessionId, exchange.advanced().isTimedOut()); - processor.process(exchange.advanced().isTimedOut() ? SessionCloseMsg.onTimeout(sessionId) : SessionCloseMsg.onError(sessionId)); - } - - @Override - public long getTimeout() { - return timeout; - } - - public void setSessionType(SessionType sessionType) { - this.sessionType = sessionType; - } - - @Override - public SessionType getSessionType() { - return sessionType; - } - - public int nextSeqNumber() { - return seqNumber.getAndIncrement(); - } -} diff --git a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java b/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java deleted file mode 100644 index 888fb2fc30..0000000000 --- a/transport/coap/src/main/java/org/thingsboard/server/transport/coap/session/CoapSessionId.java +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap.session; - -import org.thingsboard.server.common.data.id.SessionId; - -public final class CoapSessionId implements SessionId { - - private final String clientAddress; - private final int clientPort; - private final String token; - - public CoapSessionId(String host, int port, String token) { - super(); - this.clientAddress = host; - this.clientPort = port; - this.token = token; - } - - @Override - public int hashCode() { - final int prime = 31; - int result = 1; - result = prime * result + ((clientAddress == null) ? 0 : clientAddress.hashCode()); - result = prime * result + clientPort; - result = prime * result + ((token == null) ? 0 : token.hashCode()); - return result; - } - - @Override - public boolean equals(Object obj) { - if (this == obj) - return true; - if (obj == null) - return false; - if (getClass() != obj.getClass()) - return false; - CoapSessionId other = (CoapSessionId) obj; - if (clientAddress == null) { - if (other.clientAddress != null) - return false; - } else if (!clientAddress.equals(other.clientAddress)) - return false; - if (clientPort != other.clientPort) - return false; - if (token == null) { - if (other.token != null) - return false; - } else if (!token.equals(other.token)) - return false; - return true; - } - - @Override - public String toString() { - return "CoapSessionId [clientAddress=" + clientAddress + ", clientPort=" + clientPort + ", token=" + token + "]"; - } - - @Override - public String toUidStr() { - return clientAddress + ":" + clientPort + ":" + token; - } - -} diff --git a/transport/coap/src/main/resources/logback.xml b/transport/coap/src/main/resources/logback.xml new file mode 100644 index 0000000000..18864a9fab --- /dev/null +++ b/transport/coap/src/main/resources/logback.xml @@ -0,0 +1,34 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + \ No newline at end of file diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml new file mode 100644 index 0000000000..95e7b7571b --- /dev/null +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -0,0 +1,57 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +spring.main.web-environment: false +spring.main.web-application-type: none + +# MQTT server parameters +transport: + coap: + bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" + bind_port: "${COAP_BIND_PORT:5683}" + timeout: "${COAP_TIMEOUT:10000}" + sessions: + inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" + report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:30000}" + rate_limits: + enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" + tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" + device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" + json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" + +kafka: + enabled: true + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + acks: "${TB_KAFKA_ACKS:all}" + retries: "${TB_KAFKA_RETRIES:1}" + batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" + linger.ms: "${TB_KAFKA_LINGER_MS:1}" + buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + transport_api: + requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + max_requests_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + response_poll_interval: "${TB_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" + response_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" + rule_engine: + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" + notifications: + topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" + poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" + auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" diff --git a/transport/coap/src/main/scripts/control/deb/postinst b/transport/coap/src/main/scripts/control/deb/postinst new file mode 100644 index 0000000000..d4066c027b --- /dev/null +++ b/transport/coap/src/main/scripts/control/deb/postinst @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} +update-rc.d ${pkg.name} defaults + diff --git a/transport/coap/src/main/scripts/control/deb/postrm b/transport/coap/src/main/scripts/control/deb/postrm new file mode 100644 index 0000000000..61865803c3 --- /dev/null +++ b/transport/coap/src/main/scripts/control/deb/postrm @@ -0,0 +1,3 @@ +#!/bin/sh + +update-rc.d -f ${pkg.name} remove diff --git a/transport/coap/src/main/scripts/control/deb/preinst b/transport/coap/src/main/scripts/control/deb/preinst new file mode 100644 index 0000000000..6be5959285 --- /dev/null +++ b/transport/coap/src/main/scripts/control/deb/preinst @@ -0,0 +1,18 @@ +#!/bin/sh + +if ! getent group ${pkg.name} >/dev/null; then + addgroup --system ${pkg.name} +fi + +if ! getent passwd ${pkg.name} >/dev/null; then + adduser --quiet \ + --system \ + --ingroup ${pkg.name} \ + --quiet \ + --disabled-login \ + --disabled-password \ + --home ${pkg.installFolder} \ + --no-create-home \ + -gecos "Thingsboard application" \ + ${pkg.name} +fi diff --git a/transport/coap/src/main/scripts/control/deb/prerm b/transport/coap/src/main/scripts/control/deb/prerm new file mode 100644 index 0000000000..898d3efd5c --- /dev/null +++ b/transport/coap/src/main/scripts/control/deb/prerm @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -e /var/run/${pkg.name}/${pkg.name}.pid ]; then + service ${pkg.name} stop +fi diff --git a/transport/coap/src/main/scripts/control/rpm/postinst b/transport/coap/src/main/scripts/control/rpm/postinst new file mode 100644 index 0000000000..8a7a88f7e0 --- /dev/null +++ b/transport/coap/src/main/scripts/control/rpm/postinst @@ -0,0 +1,9 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} + +if [ $1 -eq 1 ] ; then + # Initial installation + systemctl --no-reload enable ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/coap/src/main/scripts/control/rpm/postrm b/transport/coap/src/main/scripts/control/rpm/postrm new file mode 100644 index 0000000000..8e1f8a2048 --- /dev/null +++ b/transport/coap/src/main/scripts/control/rpm/postrm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/coap/src/main/scripts/control/rpm/preinst b/transport/coap/src/main/scripts/control/rpm/preinst new file mode 100644 index 0000000000..e19fc884c8 --- /dev/null +++ b/transport/coap/src/main/scripts/control/rpm/preinst @@ -0,0 +1,6 @@ +#!/bin/sh + +getent group ${pkg.name} >/dev/null || groupadd -r ${pkg.name} +getent passwd ${pkg.name} >/dev/null || \ +useradd -d ${pkg.installFolder} -g ${pkg.name} -M -r ${pkg.name} -s /sbin/nologin \ +-c "Thingsboard application" diff --git a/transport/coap/src/main/scripts/control/rpm/prerm b/transport/coap/src/main/scripts/control/rpm/prerm new file mode 100644 index 0000000000..accb487b8e --- /dev/null +++ b/transport/coap/src/main/scripts/control/rpm/prerm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || : +fi diff --git a/transport/coap/src/main/scripts/control/tb-coap-transport.service b/transport/coap/src/main/scripts/control/tb-coap-transport.service new file mode 100644 index 0000000000..d456fc03c0 --- /dev/null +++ b/transport/coap/src/main/scripts/control/tb-coap-transport.service @@ -0,0 +1,11 @@ +[Unit] +Description=${pkg.name} +After=syslog.target + +[Service] +User=${pkg.name} +ExecStart=${pkg.installFolder}/bin/${pkg.name}.jar +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/transport/coap/src/main/scripts/windows/install.bat b/transport/coap/src/main/scripts/windows/install.bat new file mode 100644 index 0000000000..dba773663c --- /dev/null +++ b/transport/coap/src/main/scripts/windows/install.bat @@ -0,0 +1,87 @@ +@ECHO OFF + +setlocal ENABLEEXTENSIONS + +@ECHO Detecting Java version installed. +:CHECK_JAVA_64 +@ECHO Detecting if it is 64 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\Wow6432Node\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF NOT "%JRE_PATH2%" == "" GOTO JAVA_INSTALLED + +:CHECK_JAVA_32 +@ECHO Detecting if it is 32 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF "%JRE_PATH2%" == "" GOTO JAVA_NOT_INSTALLED + +:JAVA_INSTALLED + +@ECHO Java 1.8 found! +@ECHO Installing ${pkg.name} ... + +%BASE%${pkg.name}.exe install + +@ECHO ${pkg.name} installed successfully! + +GOTO END + +:JAVA_NOT_INSTALLED +@ECHO Java 1.8 or above is not installed +@ECHO Please go to https://java.com/ and install Java. Then retry installation. +PAUSE +GOTO END + +:END + + diff --git a/transport/coap/src/main/scripts/windows/service.xml b/transport/coap/src/main/scripts/windows/service.xml new file mode 100644 index 0000000000..f7b9d307c8 --- /dev/null +++ b/transport/coap/src/main/scripts/windows/service.xml @@ -0,0 +1,36 @@ + + ${pkg.name} + ${project.name} + ${project.description} + %BASE%\conf + ${pkg.winWrapperLogFolder} + rotate + + java + -Xloggc:%BASE%\logs\gc.log + -XX:+HeapDumpOnOutOfMemoryError + -XX:+PrintGCDetails + -XX:+PrintGCDateStamps + -XX:+PrintHeapAtGC + -XX:+PrintTenuringDistribution + -XX:+PrintGCApplicationStoppedTime + -XX:+UseGCLogFileRotation + -XX:NumberOfGCLogFiles=10 + -XX:GCLogFileSize=10M + -XX:-UseBiasedLocking + -XX:+UseTLAB + -XX:+ResizeTLAB + -XX:+PerfDisableSharedMem + -XX:+UseCondCardMark + -XX:CMSWaitDuration=10000 + -XX:+UseParNewGC + -XX:+UseConcMarkSweepGC + -XX:+CMSParallelRemarkEnabled + -XX:+CMSParallelInitialMarkEnabled + -XX:+CMSEdenChunksRecordAlways + -XX:CMSInitiatingOccupancyFraction=75 + -XX:+UseCMSInitiatingOccupancyOnly + -jar + %BASE%\lib\${pkg.name}.jar + + diff --git a/transport/coap/src/main/scripts/windows/uninstall.bat b/transport/coap/src/main/scripts/windows/uninstall.bat new file mode 100644 index 0000000000..921e4c8395 --- /dev/null +++ b/transport/coap/src/main/scripts/windows/uninstall.bat @@ -0,0 +1,9 @@ +@ECHO OFF + +@ECHO Stopping ${pkg.name} ... +net stop ${pkg.name} + +@ECHO Uninstalling ${pkg.name} ... +%~dp0${pkg.name}.exe uninstall + +@ECHO DONE. \ No newline at end of file diff --git a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java b/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java deleted file mode 100644 index e2fd7cd6c4..0000000000 --- a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTest.java +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap; - -import lombok.extern.slf4j.Slf4j; -import org.eclipse.californium.core.CoapClient; -import org.eclipse.californium.core.CoapResponse; -import org.eclipse.californium.core.coap.CoAP.ResponseCode; -import org.eclipse.californium.core.coap.MediaTypeRegistry; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.test.annotation.DirtiesContext; -import org.springframework.test.annotation.DirtiesContext.ClassMode; -import org.springframework.test.context.junit4.SpringRunner; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.CustomerId; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; -import org.thingsboard.server.common.data.kv.LongDataEntry; -import org.thingsboard.server.common.data.kv.StringDataEntry; -import org.thingsboard.server.common.data.security.DeviceCredentialsFilter; -import org.thingsboard.server.common.data.security.DeviceCredentialsType; -import org.thingsboard.server.common.data.security.DeviceTokenCredentials; -import org.thingsboard.server.common.msg.aware.SessionAwareMsg; -import org.thingsboard.server.common.msg.core.BasicGetAttributesResponse; -import org.thingsboard.server.common.msg.core.BasicRequest; -import org.thingsboard.server.common.msg.core.BasicStatusCodeResponse; -import org.thingsboard.server.common.msg.kv.BasicAttributeKVMsg; -import org.thingsboard.server.common.msg.session.*; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.auth.DeviceAuthResult; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; - -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -@RunWith(SpringRunner.class) -@DirtiesContext(classMode = ClassMode.BEFORE_CLASS) -@Slf4j -public class CoapServerTest { - - private static final int TEST_PORT = 5555; - private static final String TELEMETRY_POST_MESSAGE = "[{\"key1\":\"value1\"}]"; - private static final String TEST_ATTRIBUTES_RESPONSE = "{\"key1\":\"value1\",\"key2\":42}"; - private static final String DEVICE1_TOKEN = "Device1Token"; - - @Configuration - public static class EchoCoapServerITConfiguration extends CoapServerTestConfiguration { - - @Bean - public static DeviceAuthService authService() { - return new DeviceAuthService() { - - private final DeviceId devId = new DeviceId(UUID.randomUUID()); - - @Override - public DeviceAuthResult process(DeviceCredentialsFilter credentials) { - if (credentials != null && credentials.getCredentialsType() == DeviceCredentialsType.ACCESS_TOKEN) { - DeviceTokenCredentials tokenCredentials = (DeviceTokenCredentials) credentials; - if (tokenCredentials.getCredentialsId().equals(DEVICE1_TOKEN)) { - return DeviceAuthResult.of(devId); - } - } - return DeviceAuthResult.of("Credentials are invalid!"); - } - - @Override - public Optional findDeviceById(DeviceId deviceId) { - if (deviceId.equals(devId)) { - Device dev = new Device(); - dev.setId(devId); - dev.setTenantId(new TenantId(UUID.randomUUID())); - dev.setCustomerId(new CustomerId(UUID.randomUUID())); - return Optional.of(dev); - } else { - return Optional.empty(); - } - } - }; - } - - @Bean - public static SessionMsgProcessor sessionMsgProcessor() { - return new SessionMsgProcessor() { - - @Override - public void process(SessionAwareMsg toActorMsg) { - if (toActorMsg instanceof TransportToDeviceSessionActorMsg) { - AdaptorToSessionActorMsg sessionMsg = ((TransportToDeviceSessionActorMsg) toActorMsg).getSessionMsg(); - try { - FromDeviceMsg deviceMsg = sessionMsg.getMsg(); - ToDeviceMsg toDeviceMsg = null; - if (deviceMsg.getMsgType() == SessionMsgType.POST_TELEMETRY_REQUEST) { - toDeviceMsg = BasicStatusCodeResponse.onSuccess(deviceMsg.getMsgType(), BasicRequest.DEFAULT_REQUEST_ID); - } else if (deviceMsg.getMsgType() == SessionMsgType.GET_ATTRIBUTES_REQUEST) { - List data = new ArrayList<>(); - data.add(new BaseAttributeKvEntry(new StringDataEntry("key1", "value1"), System.currentTimeMillis())); - data.add(new BaseAttributeKvEntry(new LongDataEntry("key2", 42L), System.currentTimeMillis())); - BasicAttributeKVMsg kv = BasicAttributeKVMsg.fromClient(data); - toDeviceMsg = BasicGetAttributesResponse.onSuccess(deviceMsg.getMsgType(), BasicRequest.DEFAULT_REQUEST_ID, kv); - } - if (toDeviceMsg != null) { - sessionMsg.getSessionContext().onMsg(new BasicSessionActorToAdaptorMsg(sessionMsg.getSessionContext(), toDeviceMsg)); - } - } catch (Exception e) { - e.printStackTrace(); - } - } - } - - @Override - public void onDeviceAdded(Device device) { - - } - - - }; - } - - @Bean - public static HostRequestsQuotaService quotaService() { - return new HostRequestsQuotaService(null, null, null, null, false); - } - } - - @Autowired - private CoapTransportService service; - - @Before - public void beforeTest() { - log.info("Service info: {}", service.toString()); - } - - @Test - public void testBadJsonTelemetryPostRequest() { - CoapClient client = new CoapClient(getBaseTestUrl() + DEVICE1_TOKEN + "/" + FeatureType.TELEMETRY.name().toLowerCase()); - CoapResponse response = client.setTimeout(6000).post("test", MediaTypeRegistry.APPLICATION_JSON); - Assert.assertEquals(ResponseCode.BAD_REQUEST, response.getCode()); - log.info("Response: {}, {}", response.getCode(), response.getResponseText()); - } - - @Test - public void testNoCredentialsPostRequest() { - CoapClient client = new CoapClient(getBaseTestUrl()); - CoapResponse response = client.setTimeout(6000).post(TELEMETRY_POST_MESSAGE, MediaTypeRegistry.APPLICATION_JSON); - Assert.assertEquals(ResponseCode.BAD_REQUEST, response.getCode()); - log.info("Response: {}, {}", response.getCode(), response.getResponseText()); - } - - @Test - public void testValidJsonTelemetryPostRequest() { - CoapClient client = new CoapClient(getBaseTestUrl() + DEVICE1_TOKEN + "/" + FeatureType.TELEMETRY.name().toLowerCase()); - CoapResponse response = client.setTimeout(6000).post(TELEMETRY_POST_MESSAGE, MediaTypeRegistry.APPLICATION_JSON); - Assert.assertEquals(ResponseCode.CREATED, response.getCode()); - log.info("Response: {}, {}", response.getCode(), response.getResponseText()); - } - - @Test - public void testNoCredentialsAttributesGetRequest() { - CoapClient client = new CoapClient("coap://localhost:5555/api/v1?keys=key1,key2"); - CoapResponse response = client.setTimeout(6000).get(); - Assert.assertEquals(ResponseCode.BAD_REQUEST, response.getCode()); - } - - @Test - public void testNoKeysAttributesGetRequest() { - CoapClient client = new CoapClient(getBaseTestUrl() + DEVICE1_TOKEN + "/" + FeatureType.ATTRIBUTES.name().toLowerCase() + "?data=key1,key2"); - CoapResponse response = client.setTimeout(6000).get(); - Assert.assertEquals(ResponseCode.CONTENT, response.getCode()); - } - - @Test - public void testValidAttributesGetRequest() { - CoapClient client = new CoapClient(getBaseTestUrl() + DEVICE1_TOKEN + "/" + FeatureType.ATTRIBUTES.name().toLowerCase() + "?clientKeys=key1,key2"); - CoapResponse response = client.setTimeout(6000).get(); - Assert.assertEquals(ResponseCode.CONTENT, response.getCode()); - Assert.assertEquals(TEST_ATTRIBUTES_RESPONSE, response.getResponseText()); - log.info("Response: {}, {}", response.getCode(), response.getResponseText()); - } - - private String getBaseTestUrl() { - return "coap://localhost:" + TEST_PORT + "/api/v1/"; - } - -} diff --git a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTestConfiguration.java b/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTestConfiguration.java deleted file mode 100644 index f2cd080293..0000000000 --- a/transport/coap/src/test/java/org/thingsboard/server/transport/coap/CoapServerTestConfiguration.java +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.coap; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.PropertySource; -import org.springframework.context.support.PropertySourcesPlaceholderConfigurer; -import org.springframework.test.context.TestPropertySource; - -@Configuration -@ComponentScan({ "org.thingsboard.server.transport.coap" }) -@PropertySource("classpath:coap-transport-test.properties") -public class CoapServerTestConfiguration { - - @Bean - public static PropertySourcesPlaceholderConfigurer propertyConfig() { - return new PropertySourcesPlaceholderConfigurer(); - } - -} diff --git a/transport/coap/src/test/resources/coap-transport-test.properties b/transport/coap/src/test/resources/coap-transport-test.properties deleted file mode 100644 index 469b0348b1..0000000000 --- a/transport/coap/src/test/resources/coap-transport-test.properties +++ /dev/null @@ -1,4 +0,0 @@ -coap.bind_address=0.0.0.0 -coap.bind_port=5555 -coap.adaptor=JsonCoapAdaptor -coap.timeout=10000 \ No newline at end of file diff --git a/transport/http/build.gradle b/transport/http/build.gradle new file mode 100644 index 0000000000..6d54cb4789 --- /dev/null +++ b/transport/http/build.gradle @@ -0,0 +1,140 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +buildscript { + ext { + osPackageVersion = "3.8.0" + } + repositories { + jcenter() + } + dependencies { + classpath("com.netflix.nebula:gradle-ospackage-plugin:${osPackageVersion}") + } +} + +apply plugin: "nebula.ospackage" + +buildDir = projectBuildDir +version = projectVersion +distsDirName = "./" + +// OS Package plugin configuration +ospackage { + packageName = pkgName + version = "${project.version}" + release = 1 + os = LINUX + type = BINARY + + into pkgInstallFolder + + user pkgName + permissionGroup pkgName + + // Copy the actual .jar file + from(mainJar) { + // Strip the version from the jar filename + rename { String fileName -> + "${pkgName}.jar" + } + fileMode 0500 + into "bin" + } + + // Copy the config files + from("target/conf") { + exclude "${pkgName}.conf" + fileType CONFIG | NOREPLACE + fileMode 0754 + into "conf" + } + +} + +// Configure our RPM build task +buildRpm { + + arch = NOARCH + + version = projectVersion.replace('-', '') + archiveName = "${pkgName}.rpm" + + requires("java-1.8.0") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'rpm']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + preInstall file("${buildDir}/control/rpm/preinst") + postInstall file("${buildDir}/control/rpm/postinst") + preUninstall file("${buildDir}/control/rpm/prerm") + postUninstall file("${buildDir}/control/rpm/postrm") + + user pkgName + permissionGroup pkgName + + // Copy the system unit files + from("${buildDir}/control/${pkgName}.service") { + addParentDirs = false + fileMode 0644 + into "/usr/lib/systemd/system" + } + + directory(pkgLogFolder, 0755) + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} + +// Same as the buildRpm task +buildDeb { + + arch = "all" + + archiveName = "${pkgName}.deb" + + requires("openjdk-8-jre").or("java8-runtime").or("oracle-java8-installer").or("openjdk-8-jre-headless") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'deb']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + configurationFile("${pkgInstallFolder}/conf/${pkgName}.conf") + configurationFile("${pkgInstallFolder}/conf/${pkgName}.yml") + configurationFile("${pkgInstallFolder}/conf/logback.xml") + + preInstall file("${buildDir}/control/deb/preinst") + postInstall file("${buildDir}/control/deb/postinst") + preUninstall file("${buildDir}/control/deb/prerm") + postUninstall file("${buildDir}/control/deb/postrm") + + user pkgName + permissionGroup pkgName + + directory(pkgLogFolder, 0755) + link("/etc/init.d/${pkgName}", "${pkgInstallFolder}/bin/${pkgName}.jar") + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} diff --git a/transport/http/pom.xml b/transport/http/pom.xml index a3117d5d7f..407d995381 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,46 +20,44 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT transport org.thingsboard.transport http jar - Thingsboard HTTP Transport + Thingsboard HTTP Transport Service https://thingsboard.io UTF-8 ${basedir}/../.. + tb-http-transport + /var/log/${pkg.name} + /usr/share/${pkg.name} + ${project.build.directory}/windows - org.thingsboard.common - transport - - - org.springframework.boot - spring-boot-starter-web - provided + org.thingsboard.common.transport + http - org.slf4j - slf4j-api - - - org.slf4j - log4j-over-slf4j + org.thingsboard.common + queue - ch.qos.logback - logback-core + com.sun.winsw + winsw + bin + exe + provided - ch.qos.logback - logback-classic + org.springframework.boot + spring-boot-starter-web org.springframework.boot @@ -78,4 +76,266 @@ + + ${pkg.name}-${project.version} + + + ${project.basedir}/src/main/resources + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/resources + + logback.xml + + false + + + + + + copy-service-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/conf + true + + + + src/main/filters/unix.properties + + + + + copy-win-conf + process-resources + + copy-resources + + + ${pkg.win.dist}/conf + + + src/main/resources + + logback.xml + + false + + + src/main/conf + + tb-http-transport.conf + + true + + + + src/main/filters/windows.properties + + + + + copy-control + process-resources + + copy-resources + + + ${project.build.directory}/control + + + src/main/scripts/control + true + + + + src/main/filters/unix.properties + + + + + copy-windows-control + process-resources + + copy-resources + + + ${pkg.win.dist} + + + src/main/scripts/windows + true + + + + src/main/filters/windows.properties + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-winsw-service + package + + copy + + + + + com.sun.winsw + winsw + bin + exe + service.exe + + + ${pkg.win.dist} + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + **/logback.xml + + + + ThingsBoard HTTP Transport Service + ${project.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.thingsboard.server.http.ThingsboardHttpTransportApplication + boot + ZIP + true + true + + ${pkg.installFolder}/conf + ${pkg.unixLogFolder} + ${pkg.name}.out + ${pkg.name} + + + + + + repackage + + + + + + org.fortasoft + gradle-maven-plugin + + + build + buildDeb + buildRpm + + + -PprojectBuildDir=${project.build.directory} + -PprojectVersion=${project.version} + -PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + -PpkgName=${pkg.name} + -PpkgInstallFolder=${pkg.installFolder} + -PpkgLogFolder=${pkg.unixLogFolder} + + + + + package + + invoke + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + ${pkg.name} + + src/main/assembly/windows.xml + + + + + assembly + package + + single + + + + + + org.apache.maven.plugins + maven-install-plugin + + ${project.build.directory}/${pkg.name}.deb + ${project.artifactId} + ${project.groupId} + ${project.version} + deb + deb + + + + install-deb + package + + install-file + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + diff --git a/transport/http/src/main/assembly/windows.xml b/transport/http/src/main/assembly/windows.xml new file mode 100644 index 0000000000..82da34ed13 --- /dev/null +++ b/transport/http/src/main/assembly/windows.xml @@ -0,0 +1,71 @@ + + + windows + + + zip + + + + + + ${pkg.win.dist} + logs + + */** + + + + ${pkg.win.dist}/conf + conf + windows + + + + + + ${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + lib + ${pkg.name}.jar + + + ${pkg.win.dist}/service.exe + + ${pkg.name}.exe + + + ${pkg.win.dist}/service.xml + + ${pkg.name}.xml + windows + + + ${pkg.win.dist}/install.bat + + windows + + + ${pkg.win.dist}/uninstall.bat + + windows + + + diff --git a/transport/http/src/main/conf/logback.xml b/transport/http/src/main/conf/logback.xml new file mode 100644 index 0000000000..f36469d5f2 --- /dev/null +++ b/transport/http/src/main/conf/logback.xml @@ -0,0 +1,43 @@ + + + + + + + ${pkg.logFolder}/${pkg.name}.log + + ${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/transport/http/src/main/conf/tb-http-transport.conf b/transport/http/src/main/conf/tb-http-transport.conf new file mode 100644 index 0000000000..0afa91c18e --- /dev/null +++ b/transport/http/src/main/conf/tb-http-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -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" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/transport/http/src/main/filters/unix.properties b/transport/http/src/main/filters/unix.properties new file mode 100644 index 0000000000..8967278673 --- /dev/null +++ b/transport/http/src/main/filters/unix.properties @@ -0,0 +1 @@ +pkg.logFolder=${pkg.unixLogFolder} \ No newline at end of file diff --git a/transport/http/src/main/filters/windows.properties b/transport/http/src/main/filters/windows.properties new file mode 100644 index 0000000000..a6e48d91ba --- /dev/null +++ b/transport/http/src/main/filters/windows.properties @@ -0,0 +1,2 @@ +pkg.logFolder=${BASE}\\logs +pkg.winWrapperLogFolder=%BASE%\\logs diff --git a/transport/http/src/main/java/org/thingsboard/server/http/ThingsboardHttpTransportApplication.java b/transport/http/src/main/java/org/thingsboard/server/http/ThingsboardHttpTransportApplication.java new file mode 100644 index 0000000000..517794c346 --- /dev/null +++ b/transport/http/src/main/java/org/thingsboard/server/http/ThingsboardHttpTransportApplication.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thingsboard.server.http; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; + +import java.util.Arrays; + +@SpringBootApplication +@EnableAsync +@ComponentScan({"org.thingsboard.server.http", "org.thingsboard.server.common", "org.thingsboard.server.transport.http", "org.thingsboard.server.kafka"}) +public class ThingsboardHttpTransportApplication { + + private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; + private static final String DEFAULT_SPRING_CONFIG_PARAM = SPRING_CONFIG_NAME_KEY + "=" + "tb-http-transport"; + + public static void main(String[] args) { + SpringApplication.run(ThingsboardHttpTransportApplication.class, updateArguments(args)); + } + + private static String[] updateArguments(String[] args) { + if (Arrays.stream(args).noneMatch(arg -> arg.startsWith(SPRING_CONFIG_NAME_KEY))) { + String[] modifiedArgs = new String[args.length + 1]; + System.arraycopy(args, 0, modifiedArgs, 0, args.length); + modifiedArgs[args.length] = DEFAULT_SPRING_CONFIG_PARAM; + return modifiedArgs; + } + return args; + } +} diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java deleted file mode 100644 index d26d076b21..0000000000 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.http; - -import com.google.gson.JsonObject; -import com.google.gson.JsonParser; -import com.google.gson.JsonSyntaxException; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.util.StringUtils; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.context.request.async.DeferredResult; -import org.thingsboard.server.common.data.security.DeviceTokenCredentials; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicTransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.FromDeviceMsg; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.QuotaService; -import org.thingsboard.server.common.transport.quota.host.HostRequestsQuotaService; -import org.thingsboard.server.transport.http.session.HttpSessionCtx; - -import javax.servlet.http.HttpServletRequest; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; - -/** - * @author Andrew Shvayka - */ -@RestController -@RequestMapping("/api/v1") -@Slf4j -public class DeviceApiController { - - @Value("${http.request_timeout}") - private long defaultTimeout; - - @Autowired(required = false) - private SessionMsgProcessor processor; - - @Autowired(required = false) - private DeviceAuthService authService; - - @Autowired(required = false) - private HostRequestsQuotaService quotaService; - - @RequestMapping(value = "/{deviceToken}/attributes", method = RequestMethod.GET, produces = "application/json") - public DeferredResult getDeviceAttributes(@PathVariable("deviceToken") String deviceToken, - @RequestParam(value = "clientKeys", required = false, defaultValue = "") String clientKeys, - @RequestParam(value = "sharedKeys", required = false, defaultValue = "") String sharedKeys, - HttpServletRequest httpRequest) { - DeferredResult responseWriter = new DeferredResult(); - if (quotaExceeded(httpRequest, responseWriter)) { - return responseWriter; - } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - GetAttributesRequest request; - if (StringUtils.isEmpty(clientKeys) && StringUtils.isEmpty(sharedKeys)) { - request = new BasicGetAttributesRequest(0); - } else { - Set clientKeySet = !StringUtils.isEmpty(clientKeys) ? new HashSet<>(Arrays.asList(clientKeys.split(","))) : null; - Set sharedKeySet = !StringUtils.isEmpty(sharedKeys) ? new HashSet<>(Arrays.asList(sharedKeys.split(","))) : null; - request = new BasicGetAttributesRequest(0, clientKeySet, sharedKeySet); - } - process(ctx, request); - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } - - return responseWriter; - } - - @RequestMapping(value = "/{deviceToken}/attributes", method = RequestMethod.POST) - public DeferredResult postDeviceAttributes(@PathVariable("deviceToken") String deviceToken, - @RequestBody String json, HttpServletRequest request) { - DeferredResult responseWriter = new DeferredResult(); - if (quotaExceeded(request, responseWriter)) { - return responseWriter; - } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - try { - process(ctx, JsonConverter.convertToAttributes(new JsonParser().parse(json))); - } catch (IllegalStateException | JsonSyntaxException ex) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); - } - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } - return responseWriter; - } - - @RequestMapping(value = "/{deviceToken}/telemetry", method = RequestMethod.POST) - public DeferredResult postTelemetry(@PathVariable("deviceToken") String deviceToken, - @RequestBody String json, HttpServletRequest request) { - DeferredResult responseWriter = new DeferredResult(); - if (quotaExceeded(request, responseWriter)) { - return responseWriter; - } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - try { - process(ctx, JsonConverter.convertToTelemetry(new JsonParser().parse(json))); - } catch (IllegalStateException | JsonSyntaxException ex) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); - } - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } - return responseWriter; - } - - @RequestMapping(value = "/{deviceToken}/rpc", method = RequestMethod.GET, produces = "application/json") - public DeferredResult subscribeToCommands(@PathVariable("deviceToken") String deviceToken, - @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout, - HttpServletRequest request) { - - return subscribe(deviceToken, timeout, new RpcSubscribeMsg(), request); - } - - @RequestMapping(value = "/{deviceToken}/rpc/{requestId}", method = RequestMethod.POST) - public DeferredResult replyToCommand(@PathVariable("deviceToken") String deviceToken, - @PathVariable("requestId") Integer requestId, - @RequestBody String json, HttpServletRequest request) { - DeferredResult responseWriter = new DeferredResult(); - if (quotaExceeded(request, responseWriter)) { - return responseWriter; - } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - try { - JsonObject response = new JsonParser().parse(json).getAsJsonObject(); - process(ctx, new ToDeviceRpcResponseMsg(requestId, response.toString())); - } catch (IllegalStateException | JsonSyntaxException ex) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); - } - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } - return responseWriter; - } - - @RequestMapping(value = "/{deviceToken}/rpc", method = RequestMethod.POST) - public DeferredResult postRpcRequest(@PathVariable("deviceToken") String deviceToken, - @RequestBody String json, HttpServletRequest httpRequest) { - DeferredResult responseWriter = new DeferredResult(); - if (quotaExceeded(httpRequest, responseWriter)) { - return responseWriter; - } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - try { - JsonObject request = new JsonParser().parse(json).getAsJsonObject(); - process(ctx, new ToServerRpcRequestMsg(0, - request.get("method").getAsString(), - request.get("params").toString())); - } catch (IllegalStateException | JsonSyntaxException ex) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); - } - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } - return responseWriter; - } - - @RequestMapping(value = "/{deviceToken}/attributes/updates", method = RequestMethod.GET, produces = "application/json") - public DeferredResult subscribeToAttributes(@PathVariable("deviceToken") String deviceToken, - @RequestParam(value = "timeout", required = false, defaultValue = "0") long timeout, - HttpServletRequest httpRequest) { - - return subscribe(deviceToken, timeout, new AttributesSubscribeMsg(), httpRequest); - } - - private DeferredResult subscribe(String deviceToken, long timeout, FromDeviceMsg msg, HttpServletRequest httpRequest) { - DeferredResult responseWriter = new DeferredResult(); - if (quotaExceeded(httpRequest, responseWriter)) { - return responseWriter; - } - HttpSessionCtx ctx = getHttpSessionCtx(responseWriter, timeout); - if (ctx.login(new DeviceTokenCredentials(deviceToken))) { - try { - process(ctx, msg); - } catch (IllegalStateException | JsonSyntaxException ex) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BAD_REQUEST)); - } - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.UNAUTHORIZED)); - } - return responseWriter; - } - - private HttpSessionCtx getHttpSessionCtx(DeferredResult responseWriter) { - return getHttpSessionCtx(responseWriter, defaultTimeout); - } - - private HttpSessionCtx getHttpSessionCtx(DeferredResult responseWriter, long timeout) { - return new HttpSessionCtx(processor, authService, responseWriter, timeout != 0 ? timeout : defaultTimeout); - } - - private void process(HttpSessionCtx ctx, FromDeviceMsg request) { - AdaptorToSessionActorMsg msg = new BasicAdaptorToSessionActorMsg(ctx, request); - processor.process(new BasicTransportToDeviceSessionActorMsg(ctx.getDevice(), msg)); - } - - private boolean quotaExceeded(HttpServletRequest request, DeferredResult responseWriter) { - if (quotaService.isQuotaExceeded(request.getRemoteAddr())) { - log.warn("REST Quota exceeded for [{}] . Disconnect", request.getRemoteAddr()); - responseWriter.setResult(new ResponseEntity<>(HttpStatus.BANDWIDTH_LIMIT_EXCEEDED)); - return true; - } - return false; - } - -} diff --git a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java b/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java deleted file mode 100644 index 4732785de2..0000000000 --- a/transport/http/src/main/java/org/thingsboard/server/transport/http/session/HttpSessionCtx.java +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.http.session; - -import com.google.gson.JsonObject; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.context.request.async.DeferredResult; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.session.*; -import org.thingsboard.server.common.msg.session.ex.SessionException; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; - -import java.util.Optional; -import java.util.function.Consumer; - -/** - * @author Andrew Shvayka - */ -@Slf4j -public class HttpSessionCtx extends DeviceAwareSessionContext { - - private final SessionId sessionId; - private final long timeout; - private final DeferredResult responseWriter; - - public HttpSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, DeferredResult responseWriter, long timeout) { - super(processor, authService); - this.sessionId = new HttpSessionId(); - this.responseWriter = responseWriter; - this.timeout = timeout; - } - - @Override - public SessionType getSessionType() { - return SessionType.SYNC; - } - - @Override - public void onMsg(SessionActorToAdaptorMsg source) throws SessionException { - ToDeviceMsg msg = source.getMsg(); - switch (msg.getSessionMsgType()) { - case GET_ATTRIBUTES_RESPONSE: - reply((GetAttributesResponse) msg); - return; - case STATUS_CODE_RESPONSE: - reply((StatusCodeResponse) msg); - return; - case ATTRIBUTES_UPDATE_NOTIFICATION: - reply((AttributesUpdateNotification) msg); - return; - case TO_DEVICE_RPC_REQUEST: - reply((ToDeviceRpcRequestMsg) msg); - return; - case TO_SERVER_RPC_RESPONSE: - reply((ToServerRpcResponseMsg) msg); - return; - case RULE_ENGINE_ERROR: - reply((RuleEngineErrorMsg) msg); - return; - default: - break; - } - } - - private void reply(RuleEngineErrorMsg msg) { - HttpStatus status = HttpStatus.INTERNAL_SERVER_ERROR; - switch (msg.getError()) { - case QUEUE_PUT_TIMEOUT: - status = HttpStatus.REQUEST_TIMEOUT; - break; - default: - if (msg.getInSessionMsgType() == SessionMsgType.TO_SERVER_RPC_REQUEST) { - status = HttpStatus.BAD_REQUEST; - } - break; - } - responseWriter.setResult(new ResponseEntity<>(JsonConverter.toErrorJson(msg.getErrorMsg()).toString(), status)); - } - - private void reply(ResponseMsg msg, Consumer f) { - Optional msgError = msg.getError(); - if (!msgError.isPresent()) { - Optional msgData = msg.getData(); - if (msgData.isPresent()) { - f.accept(msgData.get()); - } - } else { - Exception e = msgError.get(); - responseWriter.setResult(new ResponseEntity<>(e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR)); - } - } - - private void reply(ToDeviceRpcRequestMsg msg) { - responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg, true).toString(), HttpStatus.OK)); - } - - private void reply(ToServerRpcResponseMsg msg) { - responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg).toString(), HttpStatus.OK)); - } - - private void reply(AttributesUpdateNotification msg) { - responseWriter.setResult(new ResponseEntity<>(JsonConverter.toJson(msg.getData(), false).toString(), HttpStatus.OK)); - } - - private void reply(GetAttributesResponse msg) { - reply(msg, payload -> { - if (payload.getClientAttributes().isEmpty() && payload.getSharedAttributes().isEmpty()) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.NOT_FOUND)); - } else { - JsonObject result = JsonConverter.toJson(payload, false); - responseWriter.setResult(new ResponseEntity<>(result.toString(), HttpStatus.OK)); - } - }); - } - - private void reply(StatusCodeResponse msg) { - reply(msg, payload -> { - if (payload == 0) { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.OK)); - } else { - responseWriter.setResult(new ResponseEntity<>(HttpStatus.valueOf(payload))); - } - }); - } - - @Override - public void onMsg(SessionCtrlMsg msg) throws SessionException { - //Do nothing - } - - @Override - public boolean isClosed() { - return false; - } - - @Override - public long getTimeout() { - return timeout; - } - - @Override - public SessionId getSessionId() { - return sessionId; - } -} diff --git a/transport/http/src/main/resources/logback.xml b/transport/http/src/main/resources/logback.xml new file mode 100644 index 0000000000..18864a9fab --- /dev/null +++ b/transport/http/src/main/resources/logback.xml @@ -0,0 +1,34 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + \ No newline at end of file diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml new file mode 100644 index 0000000000..bb1d3c4888 --- /dev/null +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -0,0 +1,58 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +server: + # Server bind address + address: "${HTTP_BIND_ADDRESS:0.0.0.0}" + # Server bind port + port: "${HTTP_BIND_PORT:8081}" + +# HTTP server parameters +transport: + http: + request_timeout: "${HTTP_REQUEST_TIMEOUT:60000}" + sessions: + inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" + report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:30000}" + rate_limits: + enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" + tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" + device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" + json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" + +kafka: + enabled: true + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + acks: "${TB_KAFKA_ACKS:all}" + retries: "${TB_KAFKA_RETRIES:1}" + batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" + linger.ms: "${TB_KAFKA_LINGER_MS:1}" + buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + transport_api: + requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + max_requests_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + response_poll_interval: "${TB_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" + response_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" + rule_engine: + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" + notifications: + topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" + poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" + auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" diff --git a/transport/http/src/main/scripts/control/deb/postinst b/transport/http/src/main/scripts/control/deb/postinst new file mode 100644 index 0000000000..d4066c027b --- /dev/null +++ b/transport/http/src/main/scripts/control/deb/postinst @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} +update-rc.d ${pkg.name} defaults + diff --git a/transport/http/src/main/scripts/control/deb/postrm b/transport/http/src/main/scripts/control/deb/postrm new file mode 100644 index 0000000000..61865803c3 --- /dev/null +++ b/transport/http/src/main/scripts/control/deb/postrm @@ -0,0 +1,3 @@ +#!/bin/sh + +update-rc.d -f ${pkg.name} remove diff --git a/transport/http/src/main/scripts/control/deb/preinst b/transport/http/src/main/scripts/control/deb/preinst new file mode 100644 index 0000000000..6be5959285 --- /dev/null +++ b/transport/http/src/main/scripts/control/deb/preinst @@ -0,0 +1,18 @@ +#!/bin/sh + +if ! getent group ${pkg.name} >/dev/null; then + addgroup --system ${pkg.name} +fi + +if ! getent passwd ${pkg.name} >/dev/null; then + adduser --quiet \ + --system \ + --ingroup ${pkg.name} \ + --quiet \ + --disabled-login \ + --disabled-password \ + --home ${pkg.installFolder} \ + --no-create-home \ + -gecos "Thingsboard application" \ + ${pkg.name} +fi diff --git a/transport/http/src/main/scripts/control/deb/prerm b/transport/http/src/main/scripts/control/deb/prerm new file mode 100644 index 0000000000..898d3efd5c --- /dev/null +++ b/transport/http/src/main/scripts/control/deb/prerm @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -e /var/run/${pkg.name}/${pkg.name}.pid ]; then + service ${pkg.name} stop +fi diff --git a/transport/http/src/main/scripts/control/rpm/postinst b/transport/http/src/main/scripts/control/rpm/postinst new file mode 100644 index 0000000000..8a7a88f7e0 --- /dev/null +++ b/transport/http/src/main/scripts/control/rpm/postinst @@ -0,0 +1,9 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} + +if [ $1 -eq 1 ] ; then + # Initial installation + systemctl --no-reload enable ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/http/src/main/scripts/control/rpm/postrm b/transport/http/src/main/scripts/control/rpm/postrm new file mode 100644 index 0000000000..8e1f8a2048 --- /dev/null +++ b/transport/http/src/main/scripts/control/rpm/postrm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/http/src/main/scripts/control/rpm/preinst b/transport/http/src/main/scripts/control/rpm/preinst new file mode 100644 index 0000000000..e19fc884c8 --- /dev/null +++ b/transport/http/src/main/scripts/control/rpm/preinst @@ -0,0 +1,6 @@ +#!/bin/sh + +getent group ${pkg.name} >/dev/null || groupadd -r ${pkg.name} +getent passwd ${pkg.name} >/dev/null || \ +useradd -d ${pkg.installFolder} -g ${pkg.name} -M -r ${pkg.name} -s /sbin/nologin \ +-c "Thingsboard application" diff --git a/transport/http/src/main/scripts/control/rpm/prerm b/transport/http/src/main/scripts/control/rpm/prerm new file mode 100644 index 0000000000..accb487b8e --- /dev/null +++ b/transport/http/src/main/scripts/control/rpm/prerm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || : +fi diff --git a/transport/http/src/main/scripts/control/tb-http-transport.service b/transport/http/src/main/scripts/control/tb-http-transport.service new file mode 100644 index 0000000000..d456fc03c0 --- /dev/null +++ b/transport/http/src/main/scripts/control/tb-http-transport.service @@ -0,0 +1,11 @@ +[Unit] +Description=${pkg.name} +After=syslog.target + +[Service] +User=${pkg.name} +ExecStart=${pkg.installFolder}/bin/${pkg.name}.jar +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/transport/http/src/main/scripts/windows/install.bat b/transport/http/src/main/scripts/windows/install.bat new file mode 100644 index 0000000000..dba773663c --- /dev/null +++ b/transport/http/src/main/scripts/windows/install.bat @@ -0,0 +1,87 @@ +@ECHO OFF + +setlocal ENABLEEXTENSIONS + +@ECHO Detecting Java version installed. +:CHECK_JAVA_64 +@ECHO Detecting if it is 64 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\Wow6432Node\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF NOT "%JRE_PATH2%" == "" GOTO JAVA_INSTALLED + +:CHECK_JAVA_32 +@ECHO Detecting if it is 32 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF "%JRE_PATH2%" == "" GOTO JAVA_NOT_INSTALLED + +:JAVA_INSTALLED + +@ECHO Java 1.8 found! +@ECHO Installing ${pkg.name} ... + +%BASE%${pkg.name}.exe install + +@ECHO ${pkg.name} installed successfully! + +GOTO END + +:JAVA_NOT_INSTALLED +@ECHO Java 1.8 or above is not installed +@ECHO Please go to https://java.com/ and install Java. Then retry installation. +PAUSE +GOTO END + +:END + + diff --git a/transport/http/src/main/scripts/windows/service.xml b/transport/http/src/main/scripts/windows/service.xml new file mode 100644 index 0000000000..f7b9d307c8 --- /dev/null +++ b/transport/http/src/main/scripts/windows/service.xml @@ -0,0 +1,36 @@ + + ${pkg.name} + ${project.name} + ${project.description} + %BASE%\conf + ${pkg.winWrapperLogFolder} + rotate + + java + -Xloggc:%BASE%\logs\gc.log + -XX:+HeapDumpOnOutOfMemoryError + -XX:+PrintGCDetails + -XX:+PrintGCDateStamps + -XX:+PrintHeapAtGC + -XX:+PrintTenuringDistribution + -XX:+PrintGCApplicationStoppedTime + -XX:+UseGCLogFileRotation + -XX:NumberOfGCLogFiles=10 + -XX:GCLogFileSize=10M + -XX:-UseBiasedLocking + -XX:+UseTLAB + -XX:+ResizeTLAB + -XX:+PerfDisableSharedMem + -XX:+UseCondCardMark + -XX:CMSWaitDuration=10000 + -XX:+UseParNewGC + -XX:+UseConcMarkSweepGC + -XX:+CMSParallelRemarkEnabled + -XX:+CMSParallelInitialMarkEnabled + -XX:+CMSEdenChunksRecordAlways + -XX:CMSInitiatingOccupancyFraction=75 + -XX:+UseCMSInitiatingOccupancyOnly + -jar + %BASE%\lib\${pkg.name}.jar + + diff --git a/transport/http/src/main/scripts/windows/uninstall.bat b/transport/http/src/main/scripts/windows/uninstall.bat new file mode 100644 index 0000000000..921e4c8395 --- /dev/null +++ b/transport/http/src/main/scripts/windows/uninstall.bat @@ -0,0 +1,9 @@ +@ECHO OFF + +@ECHO Stopping ${pkg.name} ... +net stop ${pkg.name} + +@ECHO Uninstalling ${pkg.name} ... +%~dp0${pkg.name}.exe uninstall + +@ECHO DONE. \ No newline at end of file diff --git a/transport/mqtt/build.gradle b/transport/mqtt/build.gradle new file mode 100644 index 0000000000..6d54cb4789 --- /dev/null +++ b/transport/mqtt/build.gradle @@ -0,0 +1,140 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import org.apache.tools.ant.filters.ReplaceTokens + +buildscript { + ext { + osPackageVersion = "3.8.0" + } + repositories { + jcenter() + } + dependencies { + classpath("com.netflix.nebula:gradle-ospackage-plugin:${osPackageVersion}") + } +} + +apply plugin: "nebula.ospackage" + +buildDir = projectBuildDir +version = projectVersion +distsDirName = "./" + +// OS Package plugin configuration +ospackage { + packageName = pkgName + version = "${project.version}" + release = 1 + os = LINUX + type = BINARY + + into pkgInstallFolder + + user pkgName + permissionGroup pkgName + + // Copy the actual .jar file + from(mainJar) { + // Strip the version from the jar filename + rename { String fileName -> + "${pkgName}.jar" + } + fileMode 0500 + into "bin" + } + + // Copy the config files + from("target/conf") { + exclude "${pkgName}.conf" + fileType CONFIG | NOREPLACE + fileMode 0754 + into "conf" + } + +} + +// Configure our RPM build task +buildRpm { + + arch = NOARCH + + version = projectVersion.replace('-', '') + archiveName = "${pkgName}.rpm" + + requires("java-1.8.0") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'rpm']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + preInstall file("${buildDir}/control/rpm/preinst") + postInstall file("${buildDir}/control/rpm/postinst") + preUninstall file("${buildDir}/control/rpm/prerm") + postUninstall file("${buildDir}/control/rpm/postrm") + + user pkgName + permissionGroup pkgName + + // Copy the system unit files + from("${buildDir}/control/${pkgName}.service") { + addParentDirs = false + fileMode 0644 + into "/usr/lib/systemd/system" + } + + directory(pkgLogFolder, 0755) + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} + +// Same as the buildRpm task +buildDeb { + + arch = "all" + + archiveName = "${pkgName}.deb" + + requires("openjdk-8-jre").or("java8-runtime").or("oracle-java8-installer").or("openjdk-8-jre-headless") + + from("target/conf") { + include "${pkgName}.conf" + filter(ReplaceTokens, tokens: ['pkg.platform': 'deb']) + fileType CONFIG | NOREPLACE + fileMode 0754 + into "${pkgInstallFolder}/conf" + } + + configurationFile("${pkgInstallFolder}/conf/${pkgName}.conf") + configurationFile("${pkgInstallFolder}/conf/${pkgName}.yml") + configurationFile("${pkgInstallFolder}/conf/logback.xml") + + preInstall file("${buildDir}/control/deb/preinst") + postInstall file("${buildDir}/control/deb/postinst") + preUninstall file("${buildDir}/control/deb/prerm") + postUninstall file("${buildDir}/control/deb/postrm") + + user pkgName + permissionGroup pkgName + + directory(pkgLogFolder, 0755) + link("/etc/init.d/${pkgName}", "${pkgInstallFolder}/bin/${pkgName}.jar") + link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") + link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") +} diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 798e7abaa2..6395b40365 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,53 +20,44 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT transport org.thingsboard.transport mqtt jar - Thingsboard MQTT Transport + Thingsboard MQTT Transport Service https://thingsboard.io UTF-8 ${basedir}/../.. + tb-mqtt-transport + /var/log/${pkg.name} + /usr/share/${pkg.name} + ${project.build.directory}/windows - org.thingsboard.common - transport - - - io.netty - netty-all + org.thingsboard.common.transport + mqtt - org.springframework - spring-context - - - org.slf4j - slf4j-api - - - org.slf4j - log4j-over-slf4j - - - ch.qos.logback - logback-core + org.thingsboard.common + queue - ch.qos.logback - logback-classic + org.springframework.boot + spring-boot-starter-web - com.google.guava - guava + com.sun.winsw + winsw + bin + exe + provided org.springframework.boot @@ -85,4 +76,266 @@ + + ${pkg.name}-${project.version} + + + ${project.basedir}/src/main/resources + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/resources + + logback.xml + + false + + + + + + copy-service-conf + process-resources + + copy-resources + + + ${project.build.directory}/conf + + + src/main/conf + true + + + + src/main/filters/unix.properties + + + + + copy-win-conf + process-resources + + copy-resources + + + ${pkg.win.dist}/conf + + + src/main/resources + + logback.xml + + false + + + src/main/conf + + tb-mqtt-transport.conf + + true + + + + src/main/filters/windows.properties + + + + + copy-control + process-resources + + copy-resources + + + ${project.build.directory}/control + + + src/main/scripts/control + true + + + + src/main/filters/unix.properties + + + + + copy-windows-control + process-resources + + copy-resources + + + ${pkg.win.dist} + + + src/main/scripts/windows + true + + + + src/main/filters/windows.properties + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-winsw-service + package + + copy + + + + + com.sun.winsw + winsw + bin + exe + service.exe + + + ${pkg.win.dist} + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + **/logback.xml + + + + ThingsBoard MQTT Transport Service + ${project.version} + + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.thingsboard.server.mqtt.ThingsboardMqttTransportApplication + boot + ZIP + true + true + + ${pkg.installFolder}/conf + ${pkg.unixLogFolder} + ${pkg.name}.out + ${pkg.name} + + + + + + repackage + + + + + + org.fortasoft + gradle-maven-plugin + + + build + buildDeb + buildRpm + + + -PprojectBuildDir=${project.build.directory} + -PprojectVersion=${project.version} + -PmainJar=${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + -PpkgName=${pkg.name} + -PpkgInstallFolder=${pkg.installFolder} + -PpkgLogFolder=${pkg.unixLogFolder} + + + + + package + + invoke + + + + + + org.apache.maven.plugins + maven-assembly-plugin + + ${pkg.name} + + src/main/assembly/windows.xml + + + + + assembly + package + + single + + + + + + org.apache.maven.plugins + maven-install-plugin + + ${project.build.directory}/${pkg.name}.deb + ${project.artifactId} + ${project.groupId} + ${project.version} + deb + deb + + + + install-deb + package + + install-file + + + + + + + + + jenkins + Jenkins Repository + http://repo.jenkins-ci.org/releases + + false + + + diff --git a/transport/mqtt/src/main/assembly/windows.xml b/transport/mqtt/src/main/assembly/windows.xml new file mode 100644 index 0000000000..82da34ed13 --- /dev/null +++ b/transport/mqtt/src/main/assembly/windows.xml @@ -0,0 +1,71 @@ + + + windows + + + zip + + + + + + ${pkg.win.dist} + logs + + */** + + + + ${pkg.win.dist}/conf + conf + windows + + + + + + ${project.build.directory}/${project.build.finalName}-boot.${project.packaging} + lib + ${pkg.name}.jar + + + ${pkg.win.dist}/service.exe + + ${pkg.name}.exe + + + ${pkg.win.dist}/service.xml + + ${pkg.name}.xml + windows + + + ${pkg.win.dist}/install.bat + + windows + + + ${pkg.win.dist}/uninstall.bat + + windows + + + diff --git a/transport/mqtt/src/main/conf/logback.xml b/transport/mqtt/src/main/conf/logback.xml new file mode 100644 index 0000000000..f36469d5f2 --- /dev/null +++ b/transport/mqtt/src/main/conf/logback.xml @@ -0,0 +1,43 @@ + + + + + + + ${pkg.logFolder}/${pkg.name}.log + + ${pkg.logFolder}/${pkg.name}.%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/transport/mqtt/src/main/conf/tb-mqtt-transport.conf b/transport/mqtt/src/main/conf/tb-mqtt-transport.conf new file mode 100644 index 0000000000..0afa91c18e --- /dev/null +++ b/transport/mqtt/src/main/conf/tb-mqtt-transport.conf @@ -0,0 +1,23 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +export JAVA_OPTS="$JAVA_OPTS -Xloggc:@pkg.logFolder@/gc.log -XX:+IgnoreUnrecognizedVMOptions -XX:+HeapDumpOnOutOfMemoryError -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" +export LOG_FILENAME=${pkg.name}.out +export LOADER_PATH=${pkg.installFolder}/conf diff --git a/transport/mqtt/src/main/filters/unix.properties b/transport/mqtt/src/main/filters/unix.properties new file mode 100644 index 0000000000..8967278673 --- /dev/null +++ b/transport/mqtt/src/main/filters/unix.properties @@ -0,0 +1 @@ +pkg.logFolder=${pkg.unixLogFolder} \ No newline at end of file diff --git a/transport/mqtt/src/main/filters/windows.properties b/transport/mqtt/src/main/filters/windows.properties new file mode 100644 index 0000000000..a6e48d91ba --- /dev/null +++ b/transport/mqtt/src/main/filters/windows.properties @@ -0,0 +1,2 @@ +pkg.logFolder=${BASE}\\logs +pkg.winWrapperLogFolder=%BASE%\\logs diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java b/transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java new file mode 100644 index 0000000000..2bae2ff855 --- /dev/null +++ b/transport/mqtt/src/main/java/org/thingsboard/server/mqtt/ThingsboardMqttTransportApplication.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.thingsboard.server.mqtt; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.Arrays; + +@SpringBootConfiguration +@EnableAsync +@EnableScheduling +@ComponentScan({"org.thingsboard.server.mqtt", "org.thingsboard.server.common", "org.thingsboard.server.transport.mqtt", "org.thingsboard.server.kafka"}) +public class ThingsboardMqttTransportApplication { + + private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; + private static final String DEFAULT_SPRING_CONFIG_PARAM = SPRING_CONFIG_NAME_KEY + "=" + "tb-mqtt-transport"; + + public static void main(String[] args) { + SpringApplication.run(ThingsboardMqttTransportApplication.class, updateArguments(args)); + } + + private static String[] updateArguments(String[] args) { + if (Arrays.stream(args).noneMatch(arg -> arg.startsWith(SPRING_CONFIG_NAME_KEY))) { + String[] modifiedArgs = new String[args.length + 1]; + System.arraycopy(args, 0, modifiedArgs, 0, args.length); + modifiedArgs[args.length] = DEFAULT_SPRING_CONFIG_PARAM; + return modifiedArgs; + } + return args; + } +} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java deleted file mode 100644 index 185b7a82c3..0000000000 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ /dev/null @@ -1,423 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.mqtt; - -import com.fasterxml.jackson.databind.JsonNode; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.ChannelInboundHandlerAdapter; -import io.netty.handler.codec.mqtt.*; -import io.netty.handler.codec.mqtt.MqttConnectReturnCode; -import io.netty.handler.codec.mqtt.MqttQoS; -import io.netty.handler.ssl.SslHandler; -import io.netty.util.concurrent.Future; -import io.netty.util.concurrent.GenericFutureListener; -import lombok.extern.slf4j.Slf4j; -import org.springframework.util.StringUtils; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.security.DeviceTokenCredentials; -import org.thingsboard.server.common.data.security.DeviceX509Credentials; -import org.thingsboard.server.common.msg.core.SessionOpenMsg; -import org.thingsboard.server.common.msg.session.AdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicTransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.QuotaService; -import org.thingsboard.server.dao.EncryptionUtil; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; -import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; -import org.thingsboard.server.transport.mqtt.session.GatewaySessionCtx; -import org.thingsboard.server.transport.mqtt.util.SslUtil; - -import javax.net.ssl.SSLPeerUnverifiedException; -import javax.security.cert.X509Certificate; -import java.net.InetSocketAddress; -import java.util.ArrayList; -import java.util.List; - -import static io.netty.handler.codec.mqtt.MqttConnectReturnCode.*; -import static io.netty.handler.codec.mqtt.MqttMessageType.*; -import static io.netty.handler.codec.mqtt.MqttQoS.*; -import static org.thingsboard.server.common.msg.session.SessionMsgType.*; -import static org.thingsboard.server.transport.mqtt.MqttTopics.*; - -/** - * @author Andrew Shvayka - */ -@Slf4j -public class MqttTransportHandler extends ChannelInboundHandlerAdapter implements GenericFutureListener> { - - public static final MqttQoS MAX_SUPPORTED_QOS_LVL = AT_LEAST_ONCE; - - private final DeviceSessionCtx deviceSessionCtx; - private final String sessionId; - private final MqttTransportAdaptor adaptor; - private final SessionMsgProcessor processor; - private final DeviceService deviceService; - private final DeviceAuthService authService; - private final RelationService relationService; - private final QuotaService quotaService; - private final SslHandler sslHandler; - private volatile boolean connected; - private volatile InetSocketAddress address; - private volatile GatewaySessionCtx gatewaySessionCtx; - - public MqttTransportHandler(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, - MqttTransportAdaptor adaptor, SslHandler sslHandler, QuotaService quotaService) { - this.processor = processor; - this.deviceService = deviceService; - this.relationService = relationService; - this.authService = authService; - this.adaptor = adaptor; - this.deviceSessionCtx = new DeviceSessionCtx(processor, authService, adaptor); - this.sessionId = deviceSessionCtx.getSessionId().toUidStr(); - this.sslHandler = sslHandler; - this.quotaService = quotaService; - } - - @Override - public void channelRead(ChannelHandlerContext ctx, Object msg) { - log.trace("[{}] Processing msg: {}", sessionId, msg); - if (msg instanceof MqttMessage) { - processMqttMsg(ctx, (MqttMessage) msg); - } else { - ctx.close(); - } - } - - private void processMqttMsg(ChannelHandlerContext ctx, MqttMessage msg) { - address = (InetSocketAddress) ctx.channel().remoteAddress(); - if (msg.fixedHeader() == null) { - log.info("[{}:{}] Invalid message received", address.getHostName(), address.getPort()); - processDisconnect(ctx); - return; - } - - if (quotaService.isQuotaExceeded(address.getHostName())) { - log.warn("MQTT Quota exceeded for [{}:{}] . Disconnect", address.getHostName(), address.getPort()); - processDisconnect(ctx); - return; - } - - deviceSessionCtx.setChannel(ctx); - switch (msg.fixedHeader().messageType()) { - case CONNECT: - processConnect(ctx, (MqttConnectMessage) msg); - break; - case PUBLISH: - processPublish(ctx, (MqttPublishMessage) msg); - break; - case SUBSCRIBE: - processSubscribe(ctx, (MqttSubscribeMessage) msg); - break; - case UNSUBSCRIBE: - processUnsubscribe(ctx, (MqttUnsubscribeMessage) msg); - break; - case PINGREQ: - if (checkConnected(ctx)) { - ctx.writeAndFlush(new MqttMessage(new MqttFixedHeader(PINGRESP, false, AT_MOST_ONCE, false, 0))); - } - break; - case DISCONNECT: - if (checkConnected(ctx)) { - processDisconnect(ctx); - } - break; - default: - break; - } - - } - - private void processPublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg) { - if (!checkConnected(ctx)) { - return; - } - String topicName = mqttMsg.variableHeader().topicName(); - int msgId = mqttMsg.variableHeader().messageId(); - log.trace("[{}] Processing publish msg [{}][{}]!", sessionId, topicName, msgId); - - if (topicName.startsWith(BASE_GATEWAY_API_TOPIC)) { - if (gatewaySessionCtx != null) { - gatewaySessionCtx.setChannel(ctx); - handleMqttPublishMsg(topicName, msgId, mqttMsg); - } - } else { - processDevicePublish(ctx, mqttMsg, topicName, msgId); - } - } - - private void handleMqttPublishMsg(String topicName, int msgId, MqttPublishMessage mqttMsg) { - try { - if (topicName.equals(GATEWAY_TELEMETRY_TOPIC)) { - gatewaySessionCtx.onDeviceTelemetry(mqttMsg); - } else if (topicName.equals(GATEWAY_ATTRIBUTES_TOPIC)) { - gatewaySessionCtx.onDeviceAttributes(mqttMsg); - } else if (topicName.equals(GATEWAY_ATTRIBUTES_REQUEST_TOPIC)) { - gatewaySessionCtx.onDeviceAttributesRequest(mqttMsg); - } else if (topicName.equals(GATEWAY_RPC_TOPIC)) { - gatewaySessionCtx.onDeviceRpcResponse(mqttMsg); - } else if (topicName.equals(GATEWAY_CONNECT_TOPIC)) { - gatewaySessionCtx.onDeviceConnect(mqttMsg); - } else if (topicName.equals(GATEWAY_DISCONNECT_TOPIC)) { - gatewaySessionCtx.onDeviceDisconnect(mqttMsg); - } - } catch (RuntimeException | AdaptorException e) { - log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); - } - } - - private void processDevicePublish(ChannelHandlerContext ctx, MqttPublishMessage mqttMsg, String topicName, int msgId) { - AdaptorToSessionActorMsg msg = null; - try { - if (topicName.equals(DEVICE_TELEMETRY_TOPIC)) { - msg = adaptor.convertToActorMsg(deviceSessionCtx, POST_TELEMETRY_REQUEST, mqttMsg); - } else if (topicName.equals(DEVICE_ATTRIBUTES_TOPIC)) { - msg = adaptor.convertToActorMsg(deviceSessionCtx, POST_ATTRIBUTES_REQUEST, mqttMsg); - } else if (topicName.startsWith(DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX)) { - msg = adaptor.convertToActorMsg(deviceSessionCtx, GET_ATTRIBUTES_REQUEST, mqttMsg); - if (msgId >= 0) { - ctx.writeAndFlush(createMqttPubAckMsg(msgId)); - } - } else if (topicName.startsWith(DEVICE_RPC_RESPONSE_TOPIC)) { - msg = adaptor.convertToActorMsg(deviceSessionCtx, TO_DEVICE_RPC_RESPONSE, mqttMsg); - if (msgId >= 0) { - ctx.writeAndFlush(createMqttPubAckMsg(msgId)); - } - } else if (topicName.startsWith(DEVICE_RPC_REQUESTS_TOPIC)) { - msg = adaptor.convertToActorMsg(deviceSessionCtx, TO_SERVER_RPC_REQUEST, mqttMsg); - if (msgId >= 0) { - ctx.writeAndFlush(createMqttPubAckMsg(msgId)); - } - } - } catch (AdaptorException e) { - log.warn("[{}] Failed to process publish msg [{}][{}]", sessionId, topicName, msgId, e); - } - if (msg != null) { - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - } else { - log.info("[{}] Closing current session due to invalid publish msg [{}][{}]", sessionId, topicName, msgId); - ctx.close(); - } - } - - private void processSubscribe(ChannelHandlerContext ctx, MqttSubscribeMessage mqttMsg) { - if (!checkConnected(ctx)) { - return; - } - log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); - List grantedQoSList = new ArrayList<>(); - for (MqttTopicSubscription subscription : mqttMsg.payload().topicSubscriptions()) { - String topicName = subscription.topicName(); - //TODO: handle this qos level. - MqttQoS reqQoS = subscription.qualityOfService(); - try { - if (topicName.equals(DEVICE_ATTRIBUTES_TOPIC)) { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - grantedQoSList.add(getMinSupportedQos(reqQoS)); - } else if (topicName.equals(DEVICE_RPC_REQUESTS_SUB_TOPIC)) { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, SUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - grantedQoSList.add(getMinSupportedQos(reqQoS)); - } else if (topicName.equals(DEVICE_RPC_RESPONSE_SUB_TOPIC)) { - grantedQoSList.add(getMinSupportedQos(reqQoS)); - } else if (topicName.equals(DEVICE_ATTRIBUTES_RESPONSES_TOPIC)) { - deviceSessionCtx.setAllowAttributeResponses(); - grantedQoSList.add(getMinSupportedQos(reqQoS)); - } else if (topicName.equals(GATEWAY_ATTRIBUTES_TOPIC)) { - grantedQoSList.add(getMinSupportedQos(reqQoS)); - } else { - log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topicName, reqQoS); - grantedQoSList.add(FAILURE.value()); - } - } catch (AdaptorException e) { - log.warn("[{}] Failed to subscribe to [{}][{}]", sessionId, topicName, reqQoS); - grantedQoSList.add(FAILURE.value()); - } - } - ctx.writeAndFlush(createSubAckMessage(mqttMsg.variableHeader().messageId(), grantedQoSList)); - } - - private void processUnsubscribe(ChannelHandlerContext ctx, MqttUnsubscribeMessage mqttMsg) { - if (!checkConnected(ctx)) { - return; - } - log.trace("[{}] Processing subscription [{}]!", sessionId, mqttMsg.variableHeader().messageId()); - for (String topicName : mqttMsg.payload().topics()) { - try { - if (topicName.equals(DEVICE_ATTRIBUTES_TOPIC)) { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_ATTRIBUTES_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - } else if (topicName.equals(DEVICE_RPC_REQUESTS_SUB_TOPIC)) { - AdaptorToSessionActorMsg msg = adaptor.convertToActorMsg(deviceSessionCtx, UNSUBSCRIBE_RPC_COMMANDS_REQUEST, mqttMsg); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), msg)); - } else if (topicName.equals(DEVICE_ATTRIBUTES_RESPONSES_TOPIC)) { - deviceSessionCtx.setDisallowAttributeResponses(); - } - } catch (AdaptorException e) { - log.warn("[{}] Failed to process unsubscription [{}] to [{}]", sessionId, mqttMsg.variableHeader().messageId(), topicName); - } - } - ctx.writeAndFlush(createUnSubAckMessage(mqttMsg.variableHeader().messageId())); - } - - private MqttMessage createUnSubAckMessage(int msgId) { - MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(UNSUBACK, false, AT_LEAST_ONCE, false, 0); - MqttMessageIdVariableHeader mqttMessageIdVariableHeader = MqttMessageIdVariableHeader.from(msgId); - return new MqttMessage(mqttFixedHeader, mqttMessageIdVariableHeader); - } - - private void processConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) { - log.info("[{}] Processing connect msg for client: {}!", sessionId, msg.payload().clientIdentifier()); - X509Certificate cert; - if (sslHandler != null && (cert = getX509Certificate()) != null) { - processX509CertConnect(ctx, cert); - } else { - processAuthTokenConnect(ctx, msg); - } - } - - private void processAuthTokenConnect(ChannelHandlerContext ctx, MqttConnectMessage msg) { - String userName = msg.payload().userName(); - if (StringUtils.isEmpty(userName)) { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_BAD_USER_NAME_OR_PASSWORD)); - ctx.close(); - } else if (!deviceSessionCtx.login(new DeviceTokenCredentials(msg.payload().userName()))) { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); - ctx.close(); - } else { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); - connected = true; - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new SessionOpenMsg()))); - checkGatewaySession(); - } - } - - private void processX509CertConnect(ChannelHandlerContext ctx, X509Certificate cert) { - try { - String strCert = SslUtil.getX509CertificateString(cert); - String sha3Hash = EncryptionUtil.getSha3Hash(strCert); - if (deviceSessionCtx.login(new DeviceX509Credentials(sha3Hash))) { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_ACCEPTED)); - connected = true; - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new SessionOpenMsg()))); - checkGatewaySession(); - } else { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); - ctx.close(); - } - } catch (Exception e) { - ctx.writeAndFlush(createMqttConnAckMsg(CONNECTION_REFUSED_NOT_AUTHORIZED)); - ctx.close(); - } - } - - private X509Certificate getX509Certificate() { - try { - X509Certificate[] certChain = sslHandler.engine().getSession().getPeerCertificateChain(); - if (certChain.length > 0) { - return certChain[0]; - } - } catch (SSLPeerUnverifiedException e) { - log.warn(e.getMessage()); - return null; - } - return null; - } - - private void processDisconnect(ChannelHandlerContext ctx) { - ctx.close(); - if (connected) { - processor.process(SessionCloseMsg.onDisconnect(deviceSessionCtx.getSessionId())); - if (gatewaySessionCtx != null) { - gatewaySessionCtx.onGatewayDisconnect(); - } - } - } - - private MqttConnAckMessage createMqttConnAckMsg(MqttConnectReturnCode returnCode) { - MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(CONNACK, false, AT_MOST_ONCE, false, 0); - MqttConnAckVariableHeader mqttConnAckVariableHeader = - new MqttConnAckVariableHeader(returnCode, true); - return new MqttConnAckMessage(mqttFixedHeader, mqttConnAckVariableHeader); - } - - @Override - public void channelReadComplete(ChannelHandlerContext ctx) { - ctx.flush(); - } - - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - log.error("[{}] Unexpected Exception", sessionId, cause); - ctx.close(); - } - - private static MqttSubAckMessage createSubAckMessage(Integer msgId, List grantedQoSList) { - MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(SUBACK, false, AT_LEAST_ONCE, false, 0); - MqttMessageIdVariableHeader mqttMessageIdVariableHeader = MqttMessageIdVariableHeader.from(msgId); - MqttSubAckPayload mqttSubAckPayload = new MqttSubAckPayload(grantedQoSList); - return new MqttSubAckMessage(mqttFixedHeader, mqttMessageIdVariableHeader, mqttSubAckPayload); - } - - private static int getMinSupportedQos(MqttQoS reqQoS) { - return Math.min(reqQoS.value(), MAX_SUPPORTED_QOS_LVL.value()); - } - - public static MqttPubAckMessage createMqttPubAckMsg(int requestId) { - MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(PUBACK, false, AT_LEAST_ONCE, false, 0); - MqttMessageIdVariableHeader mqttMsgIdVariableHeader = - MqttMessageIdVariableHeader.from(requestId); - return new MqttPubAckMessage(mqttFixedHeader, mqttMsgIdVariableHeader); - } - - private boolean checkConnected(ChannelHandlerContext ctx) { - if (connected) { - return true; - } else { - log.info("[{}] Closing current session due to invalid msg order [{}][{}]", sessionId); - ctx.close(); - return false; - } - } - - private void checkGatewaySession() { - Device device = deviceSessionCtx.getDevice(); - JsonNode infoNode = device.getAdditionalInfo(); - if (infoNode != null) { - JsonNode gatewayNode = infoNode.get("gateway"); - if (gatewayNode != null && gatewayNode.asBoolean()) { - gatewaySessionCtx = new GatewaySessionCtx(processor, deviceService, authService, relationService, deviceSessionCtx); - } - } - } - - @Override - public void operationComplete(Future future) throws Exception { - processor.process(SessionCloseMsg.onError(deviceSessionCtx.getSessionId())); - } -} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java deleted file mode 100644 index 432966eeff..0000000000 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportServerInitializer.java +++ /dev/null @@ -1,76 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.mqtt; - -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelPipeline; -import io.netty.channel.socket.SocketChannel; -import io.netty.handler.codec.mqtt.MqttDecoder; -import io.netty.handler.codec.mqtt.MqttEncoder; -import io.netty.handler.ssl.SslHandler; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.quota.QuotaService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; - -/** - * @author Andrew Shvayka - */ -public class MqttTransportServerInitializer extends ChannelInitializer { - - private final SessionMsgProcessor processor; - private final DeviceService deviceService; - private final DeviceAuthService authService; - private final RelationService relationService; - private final MqttTransportAdaptor adaptor; - private final MqttSslHandlerProvider sslHandlerProvider; - private final QuotaService quotaService; - private final int maxPayloadSize; - - public MqttTransportServerInitializer(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, - MqttTransportAdaptor adaptor, MqttSslHandlerProvider sslHandlerProvider, - QuotaService quotaService, int maxPayloadSize) { - this.processor = processor; - this.deviceService = deviceService; - this.authService = authService; - this.relationService = relationService; - this.adaptor = adaptor; - this.sslHandlerProvider = sslHandlerProvider; - this.quotaService = quotaService; - this.maxPayloadSize = maxPayloadSize; - } - - @Override - public void initChannel(SocketChannel ch) { - ChannelPipeline pipeline = ch.pipeline(); - SslHandler sslHandler = null; - if (sslHandlerProvider != null) { - sslHandler = sslHandlerProvider.getSslHandler(); - pipeline.addLast(sslHandler); - } - pipeline.addLast("decoder", new MqttDecoder(maxPayloadSize)); - pipeline.addLast("encoder", MqttEncoder.INSTANCE); - - MqttTransportHandler handler = new MqttTransportHandler(processor, deviceService, authService, relationService, - adaptor, sslHandler, quotaService); - - pipeline.addLast(handler); - ch.closeFuture().addListener(handler); - } - -} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java deleted file mode 100644 index f0b29cbd17..0000000000 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/JsonMqttAdaptor.java +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.mqtt.adaptors; - -import com.google.gson.Gson; -import com.google.gson.JsonElement; -import com.google.gson.JsonParser; -import com.google.gson.JsonSyntaxException; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.ByteBufAllocator; -import io.netty.buffer.UnpooledByteBufAllocator; -import io.netty.handler.codec.mqtt.*; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.*; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.transport.mqtt.MqttTopics; -import org.thingsboard.server.transport.mqtt.session.DeviceSessionCtx; -import org.thingsboard.server.transport.mqtt.MqttTransportHandler; - -import java.nio.charset.Charset; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Optional; -import java.util.Set; - -/** - * @author Andrew Shvayka - */ -@Component("JsonMqttAdaptor") -@Slf4j -public class JsonMqttAdaptor implements MqttTransportAdaptor { - - private static final Gson GSON = new Gson(); - private static final Charset UTF8 = Charset.forName("UTF-8"); - private static final ByteBufAllocator ALLOCATOR = new UnpooledByteBufAllocator(false); - - @Override - public AdaptorToSessionActorMsg convertToActorMsg(DeviceSessionCtx ctx, SessionMsgType type, MqttMessage inbound) throws AdaptorException { - FromDeviceMsg msg; - switch (type) { - case POST_TELEMETRY_REQUEST: - msg = convertToTelemetryUploadRequest(ctx, (MqttPublishMessage) inbound); - break; - case POST_ATTRIBUTES_REQUEST: - msg = convertToUpdateAttributesRequest(ctx, (MqttPublishMessage) inbound); - break; - case SUBSCRIBE_ATTRIBUTES_REQUEST: - msg = new AttributesSubscribeMsg(); - break; - case UNSUBSCRIBE_ATTRIBUTES_REQUEST: - msg = new AttributesUnsubscribeMsg(); - break; - case SUBSCRIBE_RPC_COMMANDS_REQUEST: - msg = new RpcSubscribeMsg(); - break; - case UNSUBSCRIBE_RPC_COMMANDS_REQUEST: - msg = new RpcUnsubscribeMsg(); - break; - case GET_ATTRIBUTES_REQUEST: - msg = convertToGetAttributesRequest(ctx, (MqttPublishMessage) inbound); - break; - case TO_DEVICE_RPC_RESPONSE: - msg = convertToRpcCommandResponse(ctx, (MqttPublishMessage) inbound); - break; - case TO_SERVER_RPC_REQUEST: - msg = convertToServerRpcRequest(ctx, (MqttPublishMessage) inbound); - break; - default: - log.warn("[{}] Unsupported msg type: {}!", ctx.getSessionId(), type); - throw new AdaptorException(new IllegalArgumentException("Unsupported msg type: " + type + "!")); - } - return new BasicAdaptorToSessionActorMsg(ctx, msg); - } - - @Override - public Optional convertToAdaptorMsg(DeviceSessionCtx ctx, SessionActorToAdaptorMsg sessionMsg) throws AdaptorException { - MqttMessage result = null; - ToDeviceMsg msg = sessionMsg.getMsg(); - switch (msg.getSessionMsgType()) { - case STATUS_CODE_RESPONSE: - case GET_ATTRIBUTES_RESPONSE: - ResponseMsg responseMsg = (ResponseMsg) msg; - Optional responseError = responseMsg.getError(); - if (responseMsg.isSuccess()) { - result = convertResponseMsg(ctx, msg, responseMsg, responseError); - } else { - if (responseError.isPresent()) { - throw new AdaptorException(responseError.get()); - } - } - break; - case ATTRIBUTES_UPDATE_NOTIFICATION: - AttributesUpdateNotification notification = (AttributesUpdateNotification) msg; - result = createMqttPublishMsg(ctx, MqttTopics.DEVICE_ATTRIBUTES_TOPIC, notification.getData(), false); - break; - case TO_DEVICE_RPC_REQUEST: - ToDeviceRpcRequestMsg rpcRequest = (ToDeviceRpcRequestMsg) msg; - result = createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_REQUESTS_TOPIC + rpcRequest.getRequestId(), - rpcRequest); - break; - case TO_SERVER_RPC_RESPONSE: - ToServerRpcResponseMsg rpcResponse = (ToServerRpcResponseMsg) msg; - result = createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC + rpcResponse.getRequestId(), - rpcResponse); - break; - case RULE_ENGINE_ERROR: - RuleEngineErrorMsg errorMsg = (RuleEngineErrorMsg) msg; - result = createMqttPublishMsg(ctx, "errors", JsonConverter.toErrorJson(errorMsg.getErrorMsg())); - break; - default: - break; - } - return Optional.ofNullable(result); - } - - private MqttMessage convertResponseMsg(DeviceSessionCtx ctx, ToDeviceMsg msg, - ResponseMsg responseMsg, Optional responseError) throws AdaptorException { - MqttMessage result = null; - SessionMsgType requestMsgType = responseMsg.getRequestMsgType(); - Integer requestId = responseMsg.getRequestId(); - if (requestId >= 0) { - if (requestMsgType == SessionMsgType.POST_ATTRIBUTES_REQUEST || requestMsgType == SessionMsgType.POST_TELEMETRY_REQUEST) { - result = MqttTransportHandler.createMqttPubAckMsg(requestId); - } else if (requestMsgType == SessionMsgType.GET_ATTRIBUTES_REQUEST) { - GetAttributesResponse response = (GetAttributesResponse) msg; - Optional responseData = response.getData(); - if (response.isSuccess() && responseData.isPresent()) { - result = createMqttPublishMsg(ctx, - MqttTopics.DEVICE_ATTRIBUTES_RESPONSE_TOPIC_PREFIX + requestId, - responseData.get(), true); - } else { - if (responseError.isPresent()) { - throw new AdaptorException(responseError.get()); - } - } - } - } - return result; - } - - private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, AttributesKVMsg msg, boolean asMap) { - return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg, asMap)); - } - - private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, ToDeviceRpcRequestMsg msg) { - return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg, false)); - } - - private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, ToServerRpcResponseMsg msg) { - return createMqttPublishMsg(ctx, topic, JsonConverter.toJson(msg)); - } - - private MqttPublishMessage createMqttPublishMsg(DeviceSessionCtx ctx, String topic, JsonElement json) { - MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(MqttMessageType.PUBLISH, false, MqttQoS.AT_LEAST_ONCE, false, 0); - MqttPublishVariableHeader header = new MqttPublishVariableHeader(topic, ctx.nextMsgId()); - ByteBuf payload = ALLOCATOR.buffer(); - payload.writeBytes(GSON.toJson(json).getBytes(UTF8)); - return new MqttPublishMessage(mqttFixedHeader, header, payload); - } - - private FromDeviceMsg convertToGetAttributesRequest(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { - String topicName = inbound.variableHeader().topicName(); - try { - Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX.length())); - String payload = inbound.payload().toString(UTF8); - JsonElement requestBody = new JsonParser().parse(payload); - Set clientKeys = toStringSet(requestBody, "clientKeys"); - Set sharedKeys = toStringSet(requestBody, "sharedKeys"); - if (clientKeys == null && sharedKeys == null) { - return new BasicGetAttributesRequest(requestId); - } else { - return new BasicGetAttributesRequest(requestId, clientKeys, sharedKeys); - } - } catch (RuntimeException e) { - log.warn("Failed to decode get attributes request", e); - throw new AdaptorException(e); - } - } - - private FromDeviceMsg convertToRpcCommandResponse(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { - String topicName = inbound.variableHeader().topicName(); - try { - Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_RPC_RESPONSE_TOPIC.length())); - String payload = inbound.payload().toString(UTF8); - return new ToDeviceRpcResponseMsg( - requestId, - payload); - } catch (RuntimeException e) { - log.warn("Failed to decode get attributes request", e); - throw new AdaptorException(e); - } - } - - private Set toStringSet(JsonElement requestBody, String name) { - JsonElement element = requestBody.getAsJsonObject().get(name); - if (element != null) { - return new HashSet<>(Arrays.asList(element.getAsString().split(","))); - } else { - return null; - } - } - - private AttributesUpdateRequest convertToUpdateAttributesRequest(SessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { - String payload = validatePayload(ctx.getSessionId(), inbound.payload()); - try { - return JsonConverter.convertToAttributes(new JsonParser().parse(payload), inbound.variableHeader().messageId()); - } catch (IllegalStateException | JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - - private TelemetryUploadRequest convertToTelemetryUploadRequest(SessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { - String payload = validatePayload(ctx.getSessionId(), inbound.payload()); - try { - return JsonConverter.convertToTelemetry(new JsonParser().parse(payload), inbound.variableHeader().messageId()); - } catch (IllegalStateException | JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - - private FromDeviceMsg convertToServerRpcRequest(DeviceSessionCtx ctx, MqttPublishMessage inbound) throws AdaptorException { - String topicName = inbound.variableHeader().topicName(); - String payload = validatePayload(ctx.getSessionId(), inbound.payload()); - try { - Integer requestId = Integer.valueOf(topicName.substring(MqttTopics.DEVICE_RPC_REQUESTS_TOPIC.length())); - return JsonConverter.convertToServerRpcRequest(new JsonParser().parse(payload), requestId); - } catch (IllegalStateException | JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - - public static JsonElement validateJsonPayload(SessionId sessionId, ByteBuf payloadData) throws AdaptorException { - String payload = validatePayload(sessionId, payloadData); - try { - return new JsonParser().parse(payload); - } catch (JsonSyntaxException ex) { - throw new AdaptorException(ex); - } - } - - public static String validatePayload(SessionId sessionId, ByteBuf payloadData) throws AdaptorException { - try { - String payload = payloadData.toString(UTF8); - if (payload == null) { - log.warn("[{}] Payload is empty!", sessionId.toUidStr()); - throw new AdaptorException(new IllegalArgumentException("Payload is empty!")); - } - return payload; - } finally { - payloadData.release(); - } - } - -} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java deleted file mode 100644 index 9367a04407..0000000000 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.mqtt.session; - -import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.mqtt.*; -import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.msg.session.SessionActorToAdaptorMsg; -import org.thingsboard.server.common.msg.session.SessionCtrlMsg; -import org.thingsboard.server.common.msg.session.SessionType; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import org.thingsboard.server.common.msg.session.ex.SessionException; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; -import org.thingsboard.server.transport.mqtt.adaptors.MqttTransportAdaptor; - -import java.util.concurrent.atomic.AtomicInteger; - -/** - * @author Andrew Shvayka - */ -@Slf4j -public class DeviceSessionCtx extends DeviceAwareSessionContext { - - private final MqttTransportAdaptor adaptor; - private final MqttSessionId sessionId; - private ChannelHandlerContext channel; - private volatile boolean allowAttributeResponses; - private AtomicInteger msgIdSeq = new AtomicInteger(0); - - public DeviceSessionCtx(SessionMsgProcessor processor, DeviceAuthService authService, MqttTransportAdaptor adaptor) { - super(processor, authService); - this.adaptor = adaptor; - this.sessionId = new MqttSessionId(); - } - - @Override - public SessionType getSessionType() { - return SessionType.ASYNC; - } - - @Override - public void onMsg(SessionActorToAdaptorMsg msg) throws SessionException { - try { - adaptor.convertToAdaptorMsg(this, msg).ifPresent(this::pushToNetwork); - } catch (AdaptorException e) { - //TODO: close channel with disconnect; - logAndWrap(e); - } - } - - private void logAndWrap(AdaptorException e) throws SessionException { - log.warn("Failed to convert msg: {}", e.getMessage(), e); - throw new SessionException(e); - } - - private void pushToNetwork(MqttMessage msg) { - channel.writeAndFlush(msg); - } - - @Override - public void onMsg(SessionCtrlMsg msg) throws SessionException { - if (msg instanceof SessionCloseMsg) { - pushToNetwork(new MqttMessage(new MqttFixedHeader(MqttMessageType.DISCONNECT, false, MqttQoS.AT_MOST_ONCE, false, 0))); - channel.close(); - } - } - - @Override - public boolean isClosed() { - return false; - } - - @Override - public long getTimeout() { - return 0; - } - - @Override - public SessionId getSessionId() { - return sessionId; - } - - public void setChannel(ChannelHandlerContext channel) { - this.channel = channel; - } - - public void setAllowAttributeResponses() { - allowAttributeResponses = true; - } - - public void setDisallowAttributeResponses() { - allowAttributeResponses = false; - } - - public int nextMsgId() { - return msgIdSeq.incrementAndGet(); - } -} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java deleted file mode 100644 index dd9c9215a6..0000000000 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewayDeviceSessionCtx.java +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.mqtt.session; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import io.netty.buffer.ByteBuf; -import io.netty.buffer.ByteBufAllocator; -import io.netty.buffer.UnpooledByteBufAllocator; -import io.netty.handler.codec.mqtt.*; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.KvEntry; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.kv.AttributesKVMsg; -import org.thingsboard.server.common.msg.session.*; -import org.thingsboard.server.common.msg.session.ex.SessionException; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.common.transport.session.DeviceAwareSessionContext; -import org.thingsboard.server.transport.mqtt.MqttTopics; -import org.thingsboard.server.transport.mqtt.MqttTransportHandler; - -import java.nio.charset.Charset; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Created by ashvayka on 19.01.17. - */ -public class GatewayDeviceSessionCtx extends DeviceAwareSessionContext { - - private static final Gson GSON = new Gson(); - private static final Charset UTF8 = Charset.forName("UTF-8"); - private static final ByteBufAllocator ALLOCATOR = new UnpooledByteBufAllocator(false); - public static final String DEVICE_PROPERTY = "device"; - - private GatewaySessionCtx parent; - private final MqttSessionId sessionId; - private volatile boolean closed; - private AtomicInteger msgIdSeq = new AtomicInteger(0); - - public GatewayDeviceSessionCtx(GatewaySessionCtx parent, Device device) { - super(parent.getProcessor(), parent.getAuthService(), device); - this.parent = parent; - this.sessionId = new MqttSessionId(); - } - - @Override - public SessionId getSessionId() { - return sessionId; - } - - @Override - public SessionType getSessionType() { - return SessionType.ASYNC; - } - - @Override - public void onMsg(SessionActorToAdaptorMsg sessionMsg) throws SessionException { - Optional message = getToDeviceMsg(sessionMsg); - message.ifPresent(parent::writeAndFlush); - } - - private Optional getToDeviceMsg(SessionActorToAdaptorMsg sessionMsg) { - ToDeviceMsg msg = sessionMsg.getMsg(); - switch (msg.getSessionMsgType()) { - case STATUS_CODE_RESPONSE: - ResponseMsg responseMsg = (ResponseMsg) msg; - if (responseMsg.isSuccess()) { - SessionMsgType requestMsgType = responseMsg.getRequestMsgType(); - Integer requestId = responseMsg.getRequestId(); - if (requestId >= 0 && (requestMsgType == SessionMsgType.POST_ATTRIBUTES_REQUEST || requestMsgType == SessionMsgType.POST_TELEMETRY_REQUEST)) { - return Optional.of(MqttTransportHandler.createMqttPubAckMsg(requestId)); - } - } - break; - case GET_ATTRIBUTES_RESPONSE: - GetAttributesResponse response = (GetAttributesResponse) msg; - if (response.isSuccess()) { - return Optional.of(createMqttPublishMsg(MqttTopics.GATEWAY_ATTRIBUTES_RESPONSE_TOPIC, response)); - } else { - //TODO: push error handling to the gateway - } - break; - case ATTRIBUTES_UPDATE_NOTIFICATION: - AttributesUpdateNotification notification = (AttributesUpdateNotification) msg; - return Optional.of(createMqttPublishMsg(MqttTopics.GATEWAY_ATTRIBUTES_TOPIC, notification.getData())); - case TO_DEVICE_RPC_REQUEST: - ToDeviceRpcRequestMsg rpcRequest = (ToDeviceRpcRequestMsg) msg; - return Optional.of(createMqttPublishMsg(MqttTopics.GATEWAY_RPC_TOPIC, rpcRequest)); - default: - break; - } - return Optional.empty(); - } - - @Override - public void onMsg(SessionCtrlMsg msg) throws SessionException { - //Do nothing - } - - @Override - public boolean isClosed() { - return closed; - } - - public void setClosed(boolean closed) { - this.closed = closed; - } - - @Override - public long getTimeout() { - return 0; - } - - private MqttMessage createMqttPublishMsg(String topic, GetAttributesResponse response) { - JsonObject result = new JsonObject(); - result.addProperty("id", response.getRequestId()); - result.addProperty(DEVICE_PROPERTY, device.getName()); - Optional responseData = response.getData(); - if (responseData.isPresent()) { - AttributesKVMsg msg = responseData.get(); - if (msg.getClientAttributes() != null) { - addValues(result, msg.getClientAttributes()); - } - if (msg.getSharedAttributes() != null) { - addValues(result, msg.getSharedAttributes()); - } - } - return createMqttPublishMsg(topic, result); - } - - private void addValues(JsonObject result, List kvList) { - if (kvList.size() == 1) { - addValueToJson(result, "value", kvList.get(0)); - } else { - JsonObject values; - if (result.has("values")) { - values = result.get("values").getAsJsonObject(); - } else { - values = new JsonObject(); - result.add("values", values); - } - kvList.forEach(value -> addValueToJson(values, value.getKey(), value)); - } - } - - private void addValueToJson(JsonObject json, String name, KvEntry entry) { - switch (entry.getDataType()) { - case BOOLEAN: - entry.getBooleanValue().ifPresent(aBoolean -> json.addProperty(name, aBoolean)); - break; - case STRING: - entry.getStrValue().ifPresent(aString -> json.addProperty(name, aString)); - break; - case DOUBLE: - entry.getDoubleValue().ifPresent(aDouble -> json.addProperty(name, aDouble)); - break; - case LONG: - entry.getLongValue().ifPresent(aLong -> json.addProperty(name, aLong)); - break; - } - } - - private MqttMessage createMqttPublishMsg(String topic, AttributesKVMsg data) { - JsonObject result = new JsonObject(); - result.addProperty(DEVICE_PROPERTY, device.getName()); - result.add("data", JsonConverter.toJson(data, false)); - return createMqttPublishMsg(topic, result); - } - - private MqttMessage createMqttPublishMsg(String topic, ToDeviceRpcRequestMsg data) { - JsonObject result = new JsonObject(); - result.addProperty(DEVICE_PROPERTY, device.getName()); - result.add("data", JsonConverter.toJson(data, true)); - return createMqttPublishMsg(topic, result); - } - - private MqttPublishMessage createMqttPublishMsg(String topic, JsonElement json) { - MqttFixedHeader mqttFixedHeader = - new MqttFixedHeader(MqttMessageType.PUBLISH, false, MqttQoS.AT_LEAST_ONCE, false, 0); - MqttPublishVariableHeader header = new MqttPublishVariableHeader(topic, msgIdSeq.incrementAndGet()); - ByteBuf payload = ALLOCATOR.buffer(); - payload.writeBytes(GSON.toJson(json).getBytes(UTF8)); - return new MqttPublishMessage(mqttFixedHeader, header, payload); - } - -} diff --git a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java b/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java deleted file mode 100644 index 69ed17f3b0..0000000000 --- a/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/GatewaySessionCtx.java +++ /dev/null @@ -1,273 +0,0 @@ -/** - * Copyright © 2016-2018 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.transport.mqtt.session; - -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonNull; -import com.google.gson.JsonObject; -import com.google.gson.JsonSyntaxException; -import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.mqtt.MqttMessage; -import io.netty.handler.codec.mqtt.MqttPublishMessage; -import lombok.extern.slf4j.Slf4j; -import org.springframework.util.StringUtils; -import org.thingsboard.server.common.data.Device; -import org.thingsboard.server.common.data.id.SessionId; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; -import org.thingsboard.server.common.data.relation.EntityRelation; -import org.thingsboard.server.common.msg.core.*; -import org.thingsboard.server.common.msg.session.BasicAdaptorToSessionActorMsg; -import org.thingsboard.server.common.msg.session.BasicTransportToDeviceSessionActorMsg; -import org.thingsboard.server.common.msg.session.ctrl.SessionCloseMsg; -import org.thingsboard.server.common.transport.SessionMsgProcessor; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; -import org.thingsboard.server.common.transport.auth.DeviceAuthService; -import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.transport.mqtt.MqttTransportHandler; -import org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor; - -import java.util.*; -import java.util.stream.Collectors; - -import static org.thingsboard.server.transport.mqtt.adaptors.JsonMqttAdaptor.validateJsonPayload; - -/** - * Created by ashvayka on 19.01.17. - */ -@Slf4j -public class GatewaySessionCtx { - - private static final String DEFAULT_DEVICE_TYPE = "default"; - public static final String CAN_T_PARSE_VALUE = "Can't parse value: "; - public static final String DEVICE_PROPERTY = "device"; - private final Device gateway; - private final SessionId gatewaySessionId; - private final SessionMsgProcessor processor; - private final DeviceService deviceService; - private final DeviceAuthService authService; - private final RelationService relationService; - private final Map devices; - private ChannelHandlerContext channel; - - public GatewaySessionCtx(SessionMsgProcessor processor, DeviceService deviceService, DeviceAuthService authService, RelationService relationService, DeviceSessionCtx gatewaySessionCtx) { - this.processor = processor; - this.deviceService = deviceService; - this.authService = authService; - this.relationService = relationService; - this.gateway = gatewaySessionCtx.getDevice(); - this.gatewaySessionId = gatewaySessionCtx.getSessionId(); - this.devices = new HashMap<>(); - } - - public void onDeviceConnect(MqttPublishMessage msg) throws AdaptorException { - JsonElement json = getJson(msg); - String deviceName = checkDeviceName(getDeviceName(json)); - String deviceType = getDeviceType(json); - onDeviceConnect(deviceName, deviceType); - ack(msg); - } - - private void onDeviceConnect(String deviceName, String deviceType) { - if (!devices.containsKey(deviceName)) { - Device device = deviceService.findDeviceByTenantIdAndName(gateway.getTenantId(), deviceName); - if (device == null) { - device = new Device(); - device.setTenantId(gateway.getTenantId()); - device.setName(deviceName); - device.setType(deviceType); - device.setCustomerId(gateway.getCustomerId()); - device = deviceService.saveDevice(device); - relationService.saveRelationAsync(new EntityRelation(gateway.getId(), device.getId(), "Created")); - processor.onDeviceAdded(device); - } - GatewayDeviceSessionCtx ctx = new GatewayDeviceSessionCtx(this, device); - devices.put(deviceName, ctx); - log.debug("[{}] Added device [{}] to the gateway session", gatewaySessionId, deviceName); - processor.process(new BasicTransportToDeviceSessionActorMsg(device, new BasicAdaptorToSessionActorMsg(ctx, new AttributesSubscribeMsg()))); - processor.process(new BasicTransportToDeviceSessionActorMsg(device, new BasicAdaptorToSessionActorMsg(ctx, new RpcSubscribeMsg()))); - } - } - - public void onDeviceDisconnect(MqttPublishMessage msg) throws AdaptorException { - String deviceName = checkDeviceName(getDeviceName(getJson(msg))); - GatewayDeviceSessionCtx deviceSessionCtx = devices.remove(deviceName); - if (deviceSessionCtx != null) { - processor.process(SessionCloseMsg.onDisconnect(deviceSessionCtx.getSessionId())); - deviceSessionCtx.setClosed(true); - log.debug("[{}] Removed device [{}] from the gateway session", gatewaySessionId, deviceName); - } else { - log.debug("[{}] Device [{}] was already removed from the gateway session", gatewaySessionId, deviceName); - } - ack(msg); - } - - public void onGatewayDisconnect() { - devices.forEach((k, v) -> { - processor.process(SessionCloseMsg.onDisconnect(v.getSessionId())); - }); - } - - public void onDeviceTelemetry(MqttPublishMessage mqttMsg) throws AdaptorException { - JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); - int requestId = mqttMsg.variableHeader().messageId(); - if (json.isJsonObject()) { - JsonObject jsonObj = json.getAsJsonObject(); - for (Map.Entry deviceEntry : jsonObj.entrySet()) { - String deviceName = checkDeviceConnected(deviceEntry.getKey()); - if (!deviceEntry.getValue().isJsonArray()) { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - BasicTelemetryUploadRequest request = new BasicTelemetryUploadRequest(requestId); - JsonArray deviceData = deviceEntry.getValue().getAsJsonArray(); - for (JsonElement element : deviceData) { - JsonConverter.parseWithTs(request, element.getAsJsonObject()); - } - GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); - } - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - } - - public void onDeviceRpcResponse(MqttPublishMessage mqttMsg) throws AdaptorException { - JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); - if (json.isJsonObject()) { - JsonObject jsonObj = json.getAsJsonObject(); - String deviceName = checkDeviceConnected(jsonObj.get(DEVICE_PROPERTY).getAsString()); - Integer requestId = jsonObj.get("id").getAsInt(); - String data = jsonObj.get("data").toString(); - GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, new ToDeviceRpcResponseMsg(requestId, data)))); - ack(mqttMsg); - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - } - - public void onDeviceAttributes(MqttPublishMessage mqttMsg) throws AdaptorException { - JsonElement json = validateJsonPayload(gatewaySessionId, mqttMsg.payload()); - int requestId = mqttMsg.variableHeader().messageId(); - if (json.isJsonObject()) { - JsonObject jsonObj = json.getAsJsonObject(); - for (Map.Entry deviceEntry : jsonObj.entrySet()) { - String deviceName = checkDeviceConnected(deviceEntry.getKey()); - if (!deviceEntry.getValue().isJsonObject()) { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - long ts = System.currentTimeMillis(); - BasicAttributesUpdateRequest request = new BasicAttributesUpdateRequest(requestId); - JsonObject deviceData = deviceEntry.getValue().getAsJsonObject(); - request.add(JsonConverter.parseValues(deviceData).stream().map(kv -> new BaseAttributeKvEntry(kv, ts)).collect(Collectors.toList())); - GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); - } - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - } - - public void onDeviceAttributesRequest(MqttPublishMessage msg) throws AdaptorException { - JsonElement json = validateJsonPayload(gatewaySessionId, msg.payload()); - if (json.isJsonObject()) { - JsonObject jsonObj = json.getAsJsonObject(); - int requestId = jsonObj.get("id").getAsInt(); - String deviceName = jsonObj.get(DEVICE_PROPERTY).getAsString(); - boolean clientScope = jsonObj.get("client").getAsBoolean(); - Set keys; - if (jsonObj.has("key")) { - keys = Collections.singleton(jsonObj.get("key").getAsString()); - } else { - JsonArray keysArray = jsonObj.get("keys").getAsJsonArray(); - keys = new HashSet<>(); - for (JsonElement keyObj : keysArray) { - keys.add(keyObj.getAsString()); - } - } - - BasicGetAttributesRequest request; - if (clientScope) { - request = new BasicGetAttributesRequest(requestId, keys, null); - } else { - request = new BasicGetAttributesRequest(requestId, null, keys); - } - GatewayDeviceSessionCtx deviceSessionCtx = devices.get(deviceName); - processor.process(new BasicTransportToDeviceSessionActorMsg(deviceSessionCtx.getDevice(), - new BasicAdaptorToSessionActorMsg(deviceSessionCtx, request))); - ack(msg); - } else { - throw new JsonSyntaxException(CAN_T_PARSE_VALUE + json); - } - } - - private String checkDeviceConnected(String deviceName) { - if (!devices.containsKey(deviceName)) { - log.debug("[{}] Missing device [{}] for the gateway session", gatewaySessionId, deviceName); - onDeviceConnect(deviceName, DEFAULT_DEVICE_TYPE); - } - return deviceName; - } - - private String checkDeviceName(String deviceName) { - if (StringUtils.isEmpty(deviceName)) { - throw new RuntimeException("Device name is empty!"); - } else { - return deviceName; - } - } - - private String getDeviceName(JsonElement json) throws AdaptorException { - return json.getAsJsonObject().get(DEVICE_PROPERTY).getAsString(); - } - - private String getDeviceType(JsonElement json) throws AdaptorException { - JsonElement type = json.getAsJsonObject().get("type"); - return type == null || type instanceof JsonNull ? DEFAULT_DEVICE_TYPE : type.getAsString(); - } - - private JsonElement getJson(MqttPublishMessage mqttMsg) throws AdaptorException { - return JsonMqttAdaptor.validateJsonPayload(gatewaySessionId, mqttMsg.payload()); - } - - protected SessionMsgProcessor getProcessor() { - return processor; - } - - DeviceAuthService getAuthService() { - return authService; - } - - public void setChannel(ChannelHandlerContext channel) { - this.channel = channel; - } - - private void ack(MqttPublishMessage msg) { - if (msg.variableHeader().messageId() > 0) { - writeAndFlush(MqttTransportHandler.createMqttPubAckMsg(msg.variableHeader().messageId())); - } - } - - void writeAndFlush(MqttMessage mqttMessage) { - channel.writeAndFlush(mqttMessage); - } - -} diff --git a/transport/mqtt/src/main/resources/logback.xml b/transport/mqtt/src/main/resources/logback.xml new file mode 100644 index 0000000000..18864a9fab --- /dev/null +++ b/transport/mqtt/src/main/resources/logback.xml @@ -0,0 +1,34 @@ + + + + + + + + %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + \ No newline at end of file diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml new file mode 100644 index 0000000000..719530c73f --- /dev/null +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -0,0 +1,77 @@ +# +# Copyright © 2016-2018 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +spring.main.web-environment: false +spring.main.web-application-type: none + +# MQTT server parameters +transport: + mqtt: + bind_address: "${MQTT_BIND_ADDRESS:0.0.0.0}" + bind_port: "${MQTT_BIND_PORT:1883}" + adaptor: "${MQTT_ADAPTOR_NAME:JsonMqttAdaptor}" + timeout: "${MQTT_TIMEOUT:10000}" + netty: + leak_detector_level: "${NETTY_LEAK_DETECTOR_LVL:DISABLED}" + boss_group_thread_count: "${NETTY_BOSS_GROUP_THREADS:1}" + worker_group_thread_count: "${NETTY_WORKER_GROUP_THREADS:12}" + max_payload_size: "${NETTY_MAX_PAYLOAD_SIZE:65536}" + # MQTT SSL configuration + ssl: + # Enable/disable SSL support + enabled: "${MQTT_SSL_ENABLED:false}" + # SSL protocol: See http://docs.oracle.com/javase/8/docs/technotes/guides/security/StandardNames.html#SSLContext + protocol: "${MQTT_SSL_PROTOCOL:TLSv1.2}" + # Path to the key store that holds the SSL certificate + key_store: "${MQTT_SSL_KEY_STORE:mqttserver.jks}" + # Password used to access the key store + key_store_password: "${MQTT_SSL_KEY_STORE_PASSWORD:server_ks_password}" + # Password used to access the key + key_password: "${MQTT_SSL_KEY_PASSWORD:server_key_password}" + # Type of the key store + key_store_type: "${MQTT_SSL_KEY_STORE_TYPE:JKS}" + sessions: + inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" + report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:30000}" + rate_limits: + enabled: "${TB_TRANSPORT_RATE_LIMITS_ENABLED:false}" + tenant: "${TB_TRANSPORT_RATE_LIMITS_TENANT:1000:1,20000:60}" + device: "${TB_TRANSPORT_RATE_LIMITS_DEVICE:10:1,300:60}" + json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" + +kafka: + enabled: true + bootstrap.servers: "${TB_KAFKA_SERVERS:localhost:9092}" + acks: "${TB_KAFKA_ACKS:all}" + retries: "${TB_KAFKA_RETRIES:1}" + batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" + linger.ms: "${TB_KAFKA_LINGER_MS:1}" + buffer.memory: "${TB_BUFFER_MEMORY:33554432}" + transport_api: + requests_topic: "${TB_TRANSPORT_API_REQUEST_TOPIC:tb.transport.api.requests}" + responses_topic: "${TB_TRANSPORT_API_RESPONSE_TOPIC:tb.transport.api.responses}" + max_pending_requests: "${TB_TRANSPORT_MAX_PENDING_REQUESTS:10000}" + max_requests_timeout: "${TB_TRANSPORT_MAX_REQUEST_TIMEOUT:10000}" + response_poll_interval: "${TB_TRANSPORT_RESPONSE_POLL_INTERVAL_MS:25}" + response_auto_commit_interval: "${TB_TRANSPORT_RESPONSE_AUTO_COMMIT_INTERVAL_MS:100}" + rule_engine: + topic: "${TB_RULE_ENGINE_TOPIC:tb.rule-engine}" + notifications: + topic: "${TB_TRANSPORT_NOTIFICATIONS_TOPIC:tb.transport.notifications}" + poll_interval: "${TB_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" + auto_commit_interval: "${TB_TRANSPORT_NOTIFICATIONS_AUTO_COMMIT_INTERVAL_MS:100}" diff --git a/transport/mqtt/src/main/scripts/control/deb/postinst b/transport/mqtt/src/main/scripts/control/deb/postinst new file mode 100644 index 0000000000..d4066c027b --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/deb/postinst @@ -0,0 +1,6 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} +update-rc.d ${pkg.name} defaults + diff --git a/transport/mqtt/src/main/scripts/control/deb/postrm b/transport/mqtt/src/main/scripts/control/deb/postrm new file mode 100644 index 0000000000..61865803c3 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/deb/postrm @@ -0,0 +1,3 @@ +#!/bin/sh + +update-rc.d -f ${pkg.name} remove diff --git a/transport/mqtt/src/main/scripts/control/deb/preinst b/transport/mqtt/src/main/scripts/control/deb/preinst new file mode 100644 index 0000000000..6be5959285 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/deb/preinst @@ -0,0 +1,18 @@ +#!/bin/sh + +if ! getent group ${pkg.name} >/dev/null; then + addgroup --system ${pkg.name} +fi + +if ! getent passwd ${pkg.name} >/dev/null; then + adduser --quiet \ + --system \ + --ingroup ${pkg.name} \ + --quiet \ + --disabled-login \ + --disabled-password \ + --home ${pkg.installFolder} \ + --no-create-home \ + -gecos "Thingsboard application" \ + ${pkg.name} +fi diff --git a/transport/mqtt/src/main/scripts/control/deb/prerm b/transport/mqtt/src/main/scripts/control/deb/prerm new file mode 100644 index 0000000000..898d3efd5c --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/deb/prerm @@ -0,0 +1,5 @@ +#!/bin/sh + +if [ -e /var/run/${pkg.name}/${pkg.name}.pid ]; then + service ${pkg.name} stop +fi diff --git a/transport/mqtt/src/main/scripts/control/rpm/postinst b/transport/mqtt/src/main/scripts/control/rpm/postinst new file mode 100644 index 0000000000..8a7a88f7e0 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/rpm/postinst @@ -0,0 +1,9 @@ +#!/bin/sh + +chown -R ${pkg.name}: ${pkg.logFolder} +chown -R ${pkg.name}: ${pkg.installFolder} + +if [ $1 -eq 1 ] ; then + # Initial installation + systemctl --no-reload enable ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/mqtt/src/main/scripts/control/rpm/postrm b/transport/mqtt/src/main/scripts/control/rpm/postrm new file mode 100644 index 0000000000..8e1f8a2048 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/rpm/postrm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -ge 1 ] ; then + # Package upgrade, not uninstall + systemctl try-restart ${pkg.name}.service >/dev/null 2>&1 || : +fi diff --git a/transport/mqtt/src/main/scripts/control/rpm/preinst b/transport/mqtt/src/main/scripts/control/rpm/preinst new file mode 100644 index 0000000000..e19fc884c8 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/rpm/preinst @@ -0,0 +1,6 @@ +#!/bin/sh + +getent group ${pkg.name} >/dev/null || groupadd -r ${pkg.name} +getent passwd ${pkg.name} >/dev/null || \ +useradd -d ${pkg.installFolder} -g ${pkg.name} -M -r ${pkg.name} -s /sbin/nologin \ +-c "Thingsboard application" diff --git a/transport/mqtt/src/main/scripts/control/rpm/prerm b/transport/mqtt/src/main/scripts/control/rpm/prerm new file mode 100644 index 0000000000..accb487b8e --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/rpm/prerm @@ -0,0 +1,6 @@ +#!/bin/sh + +if [ $1 -eq 0 ] ; then + # Package removal, not upgrade + systemctl --no-reload disable --now ${pkg.name}.service > /dev/null 2>&1 || : +fi diff --git a/transport/mqtt/src/main/scripts/control/tb-mqtt-transport.service b/transport/mqtt/src/main/scripts/control/tb-mqtt-transport.service new file mode 100644 index 0000000000..d456fc03c0 --- /dev/null +++ b/transport/mqtt/src/main/scripts/control/tb-mqtt-transport.service @@ -0,0 +1,11 @@ +[Unit] +Description=${pkg.name} +After=syslog.target + +[Service] +User=${pkg.name} +ExecStart=${pkg.installFolder}/bin/${pkg.name}.jar +SuccessExitStatus=143 + +[Install] +WantedBy=multi-user.target diff --git a/transport/mqtt/src/main/scripts/windows/install.bat b/transport/mqtt/src/main/scripts/windows/install.bat new file mode 100644 index 0000000000..dba773663c --- /dev/null +++ b/transport/mqtt/src/main/scripts/windows/install.bat @@ -0,0 +1,87 @@ +@ECHO OFF + +setlocal ENABLEEXTENSIONS + +@ECHO Detecting Java version installed. +:CHECK_JAVA_64 +@ECHO Detecting if it is 64 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\Wow6432Node\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF NOT "%JRE_PATH2%" == "" GOTO JAVA_INSTALLED + +:CHECK_JAVA_32 +@ECHO Detecting if it is 32 bit machine +set KEY_NAME="HKEY_LOCAL_MACHINE\Software\JavaSoft\Java Runtime Environment" +set VALUE_NAME=CurrentVersion + +FOR /F "usebackq skip=2 tokens=1-3" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName=%%A + set ValueType=%%B + set ValueValue=%%C +) +@ECHO CurrentVersion %ValueValue% + +SET KEY_NAME="%KEY_NAME:~1,-1%\%ValueValue%" +SET VALUE_NAME=JavaHome + +if defined ValueName ( + FOR /F "usebackq skip=2 tokens=1,2*" %%A IN (`REG QUERY %KEY_NAME% /v %VALUE_NAME% 2^>nul`) DO ( + set ValueName2=%%A + set ValueType2=%%B + set JRE_PATH2=%%C + + if defined ValueName2 ( + set ValueName = %ValueName2% + set ValueType = %ValueType2% + set ValueValue = %JRE_PATH2% + ) + ) +) + +IF "%JRE_PATH2%" == "" GOTO JAVA_NOT_INSTALLED + +:JAVA_INSTALLED + +@ECHO Java 1.8 found! +@ECHO Installing ${pkg.name} ... + +%BASE%${pkg.name}.exe install + +@ECHO ${pkg.name} installed successfully! + +GOTO END + +:JAVA_NOT_INSTALLED +@ECHO Java 1.8 or above is not installed +@ECHO Please go to https://java.com/ and install Java. Then retry installation. +PAUSE +GOTO END + +:END + + diff --git a/transport/mqtt/src/main/scripts/windows/service.xml b/transport/mqtt/src/main/scripts/windows/service.xml new file mode 100644 index 0000000000..f7b9d307c8 --- /dev/null +++ b/transport/mqtt/src/main/scripts/windows/service.xml @@ -0,0 +1,36 @@ + + ${pkg.name} + ${project.name} + ${project.description} + %BASE%\conf + ${pkg.winWrapperLogFolder} + rotate + + java + -Xloggc:%BASE%\logs\gc.log + -XX:+HeapDumpOnOutOfMemoryError + -XX:+PrintGCDetails + -XX:+PrintGCDateStamps + -XX:+PrintHeapAtGC + -XX:+PrintTenuringDistribution + -XX:+PrintGCApplicationStoppedTime + -XX:+UseGCLogFileRotation + -XX:NumberOfGCLogFiles=10 + -XX:GCLogFileSize=10M + -XX:-UseBiasedLocking + -XX:+UseTLAB + -XX:+ResizeTLAB + -XX:+PerfDisableSharedMem + -XX:+UseCondCardMark + -XX:CMSWaitDuration=10000 + -XX:+UseParNewGC + -XX:+UseConcMarkSweepGC + -XX:+CMSParallelRemarkEnabled + -XX:+CMSParallelInitialMarkEnabled + -XX:+CMSEdenChunksRecordAlways + -XX:CMSInitiatingOccupancyFraction=75 + -XX:+UseCMSInitiatingOccupancyOnly + -jar + %BASE%\lib\${pkg.name}.jar + + diff --git a/transport/mqtt/src/main/scripts/windows/uninstall.bat b/transport/mqtt/src/main/scripts/windows/uninstall.bat new file mode 100644 index 0000000000..921e4c8395 --- /dev/null +++ b/transport/mqtt/src/main/scripts/windows/uninstall.bat @@ -0,0 +1,9 @@ +@ECHO OFF + +@ECHO Stopping ${pkg.name} ... +net stop ${pkg.name} + +@ECHO Uninstalling ${pkg.name} ... +%~dp0${pkg.name}.exe uninstall + +@ECHO DONE. \ No newline at end of file diff --git a/transport/pom.xml b/transport/pom.xml index df2bd27e39..0bb9300b27 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,10 +20,9 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard - org.thingsboard transport pom @@ -36,23 +35,8 @@ http - coap mqtt + coap - - - org.thingsboard - dao - - - org.springframework.boot - spring-boot-autoconfigure - - - org.bouncycastle - bcprov-jdk15on - - - diff --git a/ui/.stylelintrc b/ui/.stylelintrc index c145c8b0cb..e878729b53 100644 --- a/ui/.stylelintrc +++ b/ui/.stylelintrc @@ -251,7 +251,7 @@ "fill", "stroke" ], - "property-no-vendor-prefix": null, + "property-no-vendor-prefix": true, "rule-empty-line-before": ["always", { "except": ["first-nested"], "ignore": ["after-comment"] @@ -272,7 +272,7 @@ "selector-max-type": 5, "selector-max-universal": 1, "selector-no-qualifying-type": null, - "selector-no-vendor-prefix": null, + "selector-no-vendor-prefix": true, "selector-type-no-unknown": [true, { "ignoreTypes": [ "/^md-/", @@ -287,6 +287,6 @@ "value-list-comma-newline-after": "always-multi-line", "value-list-comma-newline-before": "never-multi-line", "value-list-comma-space-after": "always-single-line", - "value-no-vendor-prefix": null + "value-no-vendor-prefix": true } } diff --git a/ui/package.json b/ui/package.json index 498de06d4f..9fb0cc54d7 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,8 +1,8 @@ { "name": "thingsboard", "private": true, - "version": "2.1.1", - "description": "Thingsboard UI", + "version": "2.2.0", + "description": "ThingsBoard UI", "licenses": [ { "type": "Apache-2.0", @@ -26,7 +26,7 @@ "angular-gridster": "^0.13.14", "angular-hotkeys": "^1.7.0", "angular-jwt": "^0.1.6", - "angular-material": "1.1.1", + "angular-material": "1.1.9", "angular-material-data-table": "^0.10.9", "angular-material-icons": "^0.7.1", "angular-material-expansion-panel": "^0.7.2", @@ -63,7 +63,7 @@ "leaflet-providers": "^1.1.17", "material-ui": "^0.16.1", "material-ui-number-input": "^5.0.16", - "md-color-picker": "^0.2.6", + "md-color-picker": "0.2.6", "mdPickers": "git://github.com/alenaksu/mdPickers.git#0.7.5", "moment": "^2.15.0", "ngclipboard": "^1.1.1", @@ -119,7 +119,7 @@ "ng-annotate-loader": "^0.1.1", "ngtemplate-loader": "^1.3.1", "node-sass": "^4.5.3", - "postcss-loader": "^0.13.0", + "postcss-loader": "^3.0.0", "raw-loader": "^0.5.1", "react-hot-loader": "^3.0.0-beta.6", "sass-loader": "^4.0.2", @@ -145,5 +145,19 @@ "node_modules", "target" ] + }, + "browserslist": [ + "> 0.5%", + "last 2 versions", + "Firefox ESR", + "not ie <= 10", + "not ie_mob <= 10", + "not bb <= 10", + "not op_mob <= 12.1" + ], + "postcss": { + "plugins": { + "autoprefixer": true + } } } diff --git a/ui/pom.xml b/ui/pom.xml index 92c5a60d6a..52c309c6ef 100644 --- a/ui/pom.xml +++ b/ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 2.1.1-SNAPSHOT + 2.2.0-SNAPSHOT thingsboard org.thingsboard diff --git a/ui/src/app/alarm/alarm-row.directive.js b/ui/src/app/alarm/alarm-row.directive.js index 37eec7cc70..681498ff76 100644 --- a/ui/src/app/alarm/alarm-row.directive.js +++ b/ui/src/app/alarm/alarm-row.directive.js @@ -50,7 +50,7 @@ export default function AlarmRowDirective($compile, $templateCache, types, $mdDi parent: angular.element($document[0].body), targetEvent: $event, fullscreen: true, - skipHide: true, + multiple: true, onShowing: function(scope, element) { onShowingCallback.onShowing(scope, element); } diff --git a/ui/src/app/api/datasource.service.js b/ui/src/app/api/datasource.service.js index dd1a34f516..27921518b7 100644 --- a/ui/src/app/api/datasource.service.js +++ b/ui/src/app/api/datasource.service.js @@ -104,6 +104,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic var listeners = []; var datasourceType = datasourceSubscription.datasourceType; var datasourceData = {}; + var dataSourceOrigData = {}; var dataKeys = {}; var subscribers = []; var history = datasourceSubscription.subscriptionTimewindow && @@ -140,7 +141,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic } } else { if (dataKey.postFuncBody && !dataKey.postFunc) { - dataKey.postFunc = new Function("time", "value", "prevValue", dataKey.postFuncBody); + dataKey.postFunc = new Function("time", "value", "prevValue", "timePrev", "prevOrigValue", dataKey.postFuncBody); } } if (datasourceType === types.datasourceType.entity || datasourceSubscription.type === types.widgetType.timeseries.value) { @@ -165,6 +166,7 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic }; dataKeys[key] = dataKey; } + dataSourceOrigData = angular.copy(datasourceData); dataKey.key = key; } if (datasourceType === types.datasourceType.function) { @@ -678,27 +680,36 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic var dataKey = dataKeyList[keyIndex]; var data = []; var prevSeries; + var prevOrigSeries; var datasourceKeyData; + var datasourceOrigKeyData; var update = false; if (realtime) { datasourceKeyData = []; + datasourceOrigKeyData = []; } else { datasourceKeyData = datasourceData[datasourceKey].data; + datasourceOrigKeyData = dataSourceOrigData[datasourceKey].data; } if (datasourceKeyData.length > 0) { prevSeries = datasourceKeyData[datasourceKeyData.length - 1]; + prevOrigSeries = datasourceOrigKeyData[datasourceOrigKeyData.length -1]; } else { prevSeries = [0, 0]; + prevOrigSeries = [0, 0]; } + dataSourceOrigData[datasourceKey].data = []; if (datasourceSubscription.type === types.widgetType.timeseries.value) { var series, time, value; for (var i = 0; i < keyData.length; i++) { series = keyData[i]; time = series[0]; + dataSourceOrigData[datasourceKey].data.push(series); value = convertValue(series[1]); if (dataKey.postFunc) { - value = dataKey.postFunc(time, value, prevSeries[1]); + value = dataKey.postFunc(time, value, prevSeries[1], prevOrigSeries[0], prevOrigSeries[1]); } + prevOrigSeries = series; series = [time, value]; data.push(series); prevSeries = series; @@ -708,9 +719,10 @@ function DatasourceSubscription(datasourceSubscription, telemetryWebsocketServic if (keyData.length > 0) { series = keyData[0]; time = series[0]; + dataSourceOrigData[datasourceKey].data.push(series); value = convertValue(series[1]); if (dataKey.postFunc) { - value = dataKey.postFunc(time, value, prevSeries[1]); + value = dataKey.postFunc(time, value, prevSeries[1], prevOrigSeries[0], prevOrigSeries[1]); } series = [time, value]; data.push(series); diff --git a/ui/src/app/api/entity-view.service.js b/ui/src/app/api/entity-view.service.js new file mode 100644 index 0000000000..23fefbc05b --- /dev/null +++ b/ui/src/app/api/entity-view.service.js @@ -0,0 +1,211 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import thingsboardTypes from '../common/types.constant'; + +export default angular.module('thingsboard.api.entityView', [thingsboardTypes]) + .factory('entityViewService', EntityViewService) + .name; + +/*@ngInject*/ +function EntityViewService($http, $q, $window, userService, attributeService, customerService, types) { + + var service = { + assignEntityViewToCustomer: assignEntityViewToCustomer, + deleteEntityView: deleteEntityView, + getCustomerEntityViews: getCustomerEntityViews, + getEntityView: getEntityView, + getTenantEntityViews: getTenantEntityViews, + saveEntityView: saveEntityView, + unassignEntityViewFromCustomer: unassignEntityViewFromCustomer, + getEntityViewAttributes: getEntityViewAttributes, + subscribeForEntityViewAttributes: subscribeForEntityViewAttributes, + unsubscribeForEntityViewAttributes: unsubscribeForEntityViewAttributes, + findByQuery: findByQuery, + getEntityViewTypes: getEntityViewTypes + } + + return service; + + function getTenantEntityViews(pageLink, applyCustomersInfo, config, type) { + var deferred = $q.defer(); + var url = '/api/tenant/entityViews?limit=' + pageLink.limit; + if (angular.isDefined(pageLink.textSearch)) { + url += '&textSearch=' + pageLink.textSearch; + } + if (angular.isDefined(pageLink.idOffset)) { + url += '&idOffset=' + pageLink.idOffset; + } + if (angular.isDefined(pageLink.textOffset)) { + url += '&textOffset=' + pageLink.textOffset; + } + if (angular.isDefined(type) && type.length) { + url += '&type=' + type; + } + $http.get(url, config).then(function success(response) { + if (applyCustomersInfo) { + customerService.applyAssignedCustomersInfo(response.data.data).then( + function success(data) { + response.data.data = data; + deferred.resolve(response.data); + }, + function fail() { + deferred.reject(); + } + ); + } else { + deferred.resolve(response.data); + } + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function getCustomerEntityViews(customerId, pageLink, applyCustomersInfo, config, type) { + var deferred = $q.defer(); + var url = '/api/customer/' + customerId + '/entityViews?limit=' + pageLink.limit; + if (angular.isDefined(pageLink.textSearch)) { + url += '&textSearch=' + pageLink.textSearch; + } + if (angular.isDefined(pageLink.idOffset)) { + url += '&idOffset=' + pageLink.idOffset; + } + if (angular.isDefined(pageLink.textOffset)) { + url += '&textOffset=' + pageLink.textOffset; + } + if (angular.isDefined(type) && type.length) { + url += '&type=' + type; + } + $http.get(url, config).then(function success(response) { + if (applyCustomersInfo) { + customerService.applyAssignedCustomerInfo(response.data.data, customerId).then( + function success(data) { + response.data.data = data; + deferred.resolve(response.data); + }, + function fail() { + deferred.reject(); + } + ); + } else { + deferred.resolve(response.data); + } + }, function fail() { + deferred.reject(); + }); + + return deferred.promise; + } + + function getEntityView(entityViewId, ignoreErrors, config) { + var deferred = $q.defer(); + var url = '/api/entityView/' + entityViewId; + if (!config) { + config = {}; + } + config = Object.assign(config, { ignoreErrors: ignoreErrors }); + $http.get(url, config).then(function success(response) { + deferred.resolve(response.data); + }, function fail(response) { + deferred.reject(response.data); + }); + return deferred.promise; + } + + function saveEntityView(entityView) { + var deferred = $q.defer(); + var url = '/api/entityView'; + + $http.post(url, entityView).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function deleteEntityView(entityViewId) { + var deferred = $q.defer(); + var url = '/api/entityView/' + entityViewId; + $http.delete(url).then(function success() { + deferred.resolve(); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function assignEntityViewToCustomer(customerId, entityViewId) { + var deferred = $q.defer(); + var url = '/api/customer/' + customerId + '/entityView/' + entityViewId; + $http.post(url, null).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function unassignEntityViewFromCustomer(entityViewId) { + var deferred = $q.defer(); + var url = '/api/customer/entityView/' + entityViewId; + $http.delete(url).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function getEntityViewAttributes(entityViewId, attributeScope, query, successCallback, config) { + return attributeService.getEntityAttributes(types.entityType.entityView, entityViewId, attributeScope, query, successCallback, config); + } + + function subscribeForEntityViewAttributes(entityViewId, attributeScope) { + return attributeService.subscribeForEntityAttributes(types.entityType.entityView, entityViewId, attributeScope); + } + + function unsubscribeForEntityViewAttributes(subscriptionId) { + attributeService.unsubscribeForEntityAttributes(subscriptionId); + } + + function findByQuery(query, ignoreErrors, config) { + var deferred = $q.defer(); + var url = '/api/entityViews'; + if (!config) { + config = {}; + } + config = Object.assign(config, { ignoreErrors: ignoreErrors }); + $http.post(url, query, config).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + + function getEntityViewTypes(config) { + var deferred = $q.defer(); + var url = '/api/entityView/types'; + $http.get(url, config).then(function success(response) { + deferred.resolve(response.data); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + +} diff --git a/ui/src/app/api/entity.service.js b/ui/src/app/api/entity.service.js index 2e29238dfe..00c7ecf990 100644 --- a/ui/src/app/api/entity.service.js +++ b/ui/src/app/api/entity.service.js @@ -20,8 +20,9 @@ export default angular.module('thingsboard.api.entity', [thingsboardTypes]) .name; /*@ngInject*/ -function EntityService($http, $q, $filter, $translate, $log, userService, deviceService, - assetService, tenantService, customerService, ruleChainService, dashboardService, entityRelationService, attributeService, types, utils) { +function EntityService($http, $q, $filter, $translate, $log, userService, deviceService, assetService, tenantService, + customerService, ruleChainService, dashboardService, entityRelationService, attributeService, + entityViewService, types, utils) { var service = { getEntity: getEntity, getEntities: getEntities, @@ -54,6 +55,9 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device case types.entityType.asset: promise = assetService.getAsset(entityId, true, config); break; + case types.entityType.entityView: + promise = entityViewService.getEntityView(entityId, true, config); + break; case types.entityType.tenant: promise = tenantService.getTenant(entityId, config); break; @@ -131,6 +135,10 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device case types.entityType.asset: promise = assetService.getAssets(entityIds, config); break; + case types.entityType.entityView: + promise = getEntitiesByIdsPromise( + (id) => entityViewService.getEntityView(id, config), entityIds); + break; case types.entityType.tenant: promise = getEntitiesByIdsPromise( (id) => tenantService.getTenant(id, config), entityIds); @@ -239,6 +247,13 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device promise = assetService.getTenantAssets(pageLink, false, config, subType); } break; + case types.entityType.entityView: + if (user.authority === 'CUSTOMER_USER') { + promise = entityViewService.getCustomerEntityViews(customerId, pageLink, false, config, subType); + } else { + promise = entityViewService.getTenantEntityViews(pageLink, false, config, subType); + } + break; case types.entityType.tenant: if (user.authority === 'TENANT_ADMIN') { promise = getSingleTenantByPageLinkPromise(pageLink, config); @@ -522,6 +537,21 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device } ); break; + case types.aliasFilterType.entityViewType.value: + getEntitiesByNameFilter(types.entityType.entityView, filter.entityViewNameFilter, maxItems, {ignoreLoading: true}, filter.entityViewType).then( + function success(entities) { + if (entities && entities.length || !failOnEmpty) { + result.entities = entitiesToEntitiesInfo(entities); + deferred.resolve(result); + } else { + deferred.reject(); + } + }, + function fail() { + deferred.reject(); + } + ); + break; case types.aliasFilterType.relationsQuery.value: result.stateEntity = filter.rootStateEntity; var rootEntityType; @@ -567,6 +597,7 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device break; case types.aliasFilterType.assetSearchQuery.value: case types.aliasFilterType.deviceSearchQuery.value: + case types.aliasFilterType.entityViewSearchQuery.value: result.stateEntity = filter.rootStateEntity; if (result.stateEntity && stateEntityId) { rootEntityType = stateEntityId.entityType; @@ -593,6 +624,9 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device } else if (filter.type == types.aliasFilterType.deviceSearchQuery.value) { searchQuery.deviceTypes = filter.deviceTypes; findByQueryPromise = deviceService.findByQuery(searchQuery, false, {ignoreLoading: true}); + } else if (filter.type == types.aliasFilterType.entityViewSearchQuery.value) { + searchQuery.entityViewTypes = filter.entityViewTypes; + findByQueryPromise = entityViewService.findByQuery(searchQuery, false, {ignoreLoading: true}); } findByQueryPromise.then( function success(entities) { @@ -635,6 +669,8 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device return entityTypes.indexOf(types.entityType.asset) > -1 ? true : false; case types.aliasFilterType.deviceType.value: return entityTypes.indexOf(types.entityType.device) > -1 ? true : false; + case types.aliasFilterType.entityViewType.value: + return entityTypes.indexOf(types.entityType.entityView) > -1 ? true : false; case types.aliasFilterType.relationsQuery.value: if (filter.filters && filter.filters.length) { var match = false; @@ -660,6 +696,8 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device return entityTypes.indexOf(types.entityType.asset) > -1 ? true : false; case types.aliasFilterType.deviceSearchQuery.value: return entityTypes.indexOf(types.entityType.device) > -1 ? true : false; + case types.aliasFilterType.entityViewSearchQuery.value: + return entityTypes.indexOf(types.entityType.entityView) > -1 ? true : false; } } return false; @@ -679,12 +717,16 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device return entityType === types.entityType.asset; case types.aliasFilterType.deviceType.value: return entityType === types.entityType.device; + case types.aliasFilterType.entityViewType.value: + return entityType === types.entityType.entityView; case types.aliasFilterType.relationsQuery.value: return true; case types.aliasFilterType.assetSearchQuery.value: return entityType === types.entityType.asset; case types.aliasFilterType.deviceSearchQuery.value: return entityType === types.entityType.device; + case types.aliasFilterType.entityViewSearchQuery.value: + return entityType === types.entityType.entityView; } return false; } @@ -725,6 +767,7 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device case 'TENANT_ADMIN': entityTypes.device = types.entityType.device; entityTypes.asset = types.entityType.asset; + entityTypes.entityView = types.entityType.entityView; entityTypes.tenant = types.entityType.tenant; entityTypes.customer = types.entityType.customer; entityTypes.dashboard = types.entityType.dashboard; @@ -735,6 +778,7 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device case 'CUSTOMER_USER': entityTypes.device = types.entityType.device; entityTypes.asset = types.entityType.asset; + entityTypes.entityView = types.entityType.entityView; entityTypes.customer = types.entityType.customer; entityTypes.dashboard = types.entityType.dashboard; if (useAliasEntityTypes) { @@ -1033,6 +1077,8 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device return assetService.deleteAsset(entityId.id); } else if (entityId.entityType == types.entityType.device) { return deviceService.deleteDevice(entityId.id); + } else if (entityId.entityType == types.entityType.entityView) { + return entityViewService.deleteEntityView(entityId.id); } } @@ -1138,6 +1184,8 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device return assetService.saveAsset(entity); } else if (entityType == types.entityType.device) { return deviceService.saveDevice(entity); + } else if (entityType == types.entityType.entityView) { + return entityViewService.saveEntityView(entity); } } @@ -1266,6 +1314,8 @@ function EntityService($http, $q, $filter, $translate, $log, userService, device searchQuery.assetTypes = entitySubTypes; } else if (entityType == types.entityType.device) { searchQuery.deviceTypes = entitySubTypes; + } else if (entityType == types.entityType.entityView) { + searchQuery.entityViewTypes = entitySubTypes; } else { return null; //Not supported } diff --git a/ui/src/app/api/subscription.js b/ui/src/app/api/subscription.js index 350b1ef0b7..4a38f7166c 100644 --- a/ui/src/app/api/subscription.js +++ b/ui/src/app/api/subscription.js @@ -172,9 +172,9 @@ export default class Subscription { if (this.type === this.ctx.types.widgetType.rpc.value) { if (this.targetDeviceId) { entityId = { - entityType: this.ctx.entityType.device, + entityType: this.ctx.types.entityType.device, id: this.targetDeviceId - } + }; entityName = this.targetDeviceName; } } else if (this.type == this.ctx.types.widgetType.alarm.value) { @@ -182,7 +182,7 @@ export default class Subscription { entityId = { entityType: this.alarmSource.entityType, id: this.alarmSource.entityId - } + }; entityName = this.alarmSource.entityName; } } else { @@ -192,7 +192,7 @@ export default class Subscription { entityId = { entityType: datasource.entityType, id: datasource.entityId - } + }; entityName = datasource.entityName; break; } @@ -267,6 +267,14 @@ export default class Subscription { } else { this.startWatchingTimewindow(); } + registration = this.ctx.$scope.$watch(function () { + return subscription.alarmSearchStatus; + }, function (newAlarmSearchStatus, prevAlarmSearchStatus) { + if (!angular.equals(newAlarmSearchStatus, prevAlarmSearchStatus)) { + subscription.update(); + } + }, true); + this.registrations.push(registration); } initDataSubscription() { diff --git a/ui/src/app/api/time.service.js b/ui/src/app/api/time.service.js index b8d0c41cbc..56447aa212 100644 --- a/ui/src/app/api/time.service.js +++ b/ui/src/app/api/time.service.js @@ -26,15 +26,17 @@ const MIN_INTERVAL = SECOND; const MAX_INTERVAL = 365 * 20 * DAY; const MIN_LIMIT = 10; -const AVG_LIMIT = 200; -const MAX_LIMIT = 500; +//const AVG_LIMIT = 200; +//const MAX_LIMIT = 500; /*@ngInject*/ -function TimeService($translate, types) { +function TimeService($translate, $http, $q, types) { var predefIntervals; + var maxDatapointsLimit; var service = { + loadMaxDatapointsLimit: loadMaxDatapointsLimit, minIntervalLimit: minIntervalLimit, maxIntervalLimit: maxIntervalLimit, boundMinInterval: boundMinInterval, @@ -45,20 +47,38 @@ function TimeService($translate, types) { defaultTimewindow: defaultTimewindow, toHistoryTimewindow: toHistoryTimewindow, createSubscriptionTimewindow: createSubscriptionTimewindow, - avgAggregationLimit: function () { - return AVG_LIMIT; + getMaxDatapointsLimit: function () { + return maxDatapointsLimit; + }, + getMinDatapointsLimit: function () { + return MIN_LIMIT; } } return service; + function loadMaxDatapointsLimit() { + var deferred = $q.defer(); + var url = '/api/dashboard/maxDatapointsLimit'; + $http.get(url, {ignoreLoading: true}).then(function success(response) { + maxDatapointsLimit = response.data; + if (!maxDatapointsLimit || maxDatapointsLimit <= MIN_LIMIT) { + maxDatapointsLimit = MIN_LIMIT + 1; + } + deferred.resolve(); + }, function fail() { + deferred.reject(); + }); + return deferred.promise; + } + function minIntervalLimit(timewindow) { - var min = timewindow / MAX_LIMIT; + var min = timewindow / 500; return boundMinInterval(min); } function avgInterval(timewindow) { - var avg = timewindow / AVG_LIMIT; + var avg = timewindow / 200; return boundMinInterval(avg); } @@ -230,7 +250,7 @@ function TimeService($translate, types) { }, aggregation: { type: types.aggregation.avg.value, - limit: AVG_LIMIT + limit: Math.floor(maxDatapointsLimit / 2) } } return timewindow; @@ -246,22 +266,27 @@ function TimeService($translate, types) { } var aggType; + var limit; if (timewindow.aggregation) { aggType = timewindow.aggregation.type || types.aggregation.avg.value; + limit = timewindow.aggregation.limit || maxDatapointsLimit; } else { aggType = types.aggregation.avg.value; + limit = maxDatapointsLimit; } + var historyTimewindow = { history: { fixedTimewindow: { startTimeMs: startTimeMs, endTimeMs: endTimeMs }, - interval: boundIntervalToTimewindow(endTimeMs - startTimeMs, interval, aggType) + interval: boundIntervalToTimewindow(endTimeMs - startTimeMs, interval, types.aggregation.avg.value) }, aggregation: { - type: aggType + type: aggType, + limit: limit } } @@ -275,7 +300,7 @@ function TimeService($translate, types) { realtimeWindowMs: null, aggregation: { interval: SECOND, - limit: AVG_LIMIT, + limit: maxDatapointsLimit, type: types.aggregation.avg.value } }; @@ -283,14 +308,14 @@ function TimeService($translate, types) { if (stateData) { subscriptionTimewindow.aggregation = { interval: SECOND, - limit: MAX_LIMIT, + limit: maxDatapointsLimit, type: types.aggregation.none.value, stateData: true }; } else { subscriptionTimewindow.aggregation = { interval: SECOND, - limit: AVG_LIMIT, + limit: maxDatapointsLimit, type: types.aggregation.avg.value }; } @@ -298,7 +323,7 @@ function TimeService($translate, types) { if (angular.isDefined(timewindow.aggregation) && !stateData) { subscriptionTimewindow.aggregation = { type: timewindow.aggregation.type || types.aggregation.avg.value, - limit: timewindow.aggregation.limit || AVG_LIMIT + limit: timewindow.aggregation.limit || maxDatapointsLimit }; } if (angular.isDefined(timewindow.realtime)) { diff --git a/ui/src/app/api/user.service.js b/ui/src/app/api/user.service.js index fa4d63c8e2..a49c4a040a 100644 --- a/ui/src/app/api/user.service.js +++ b/ui/src/app/api/user.service.js @@ -22,7 +22,7 @@ export default angular.module('thingsboard.api.user', [thingsboardApiLogin, .name; /*@ngInject*/ -function UserService($http, $q, $rootScope, adminService, dashboardService, loginService, toast, store, jwtHelper, $translate, $state, $location) { +function UserService($http, $q, $rootScope, adminService, dashboardService, timeService, loginService, toast, store, jwtHelper, $translate, $state, $location) { var currentUser = null, currentUserDetails = null, lastPublicDashboardId = null, @@ -390,6 +390,7 @@ function UserService($http, $q, $rootScope, adminService, dashboardService, logi function loadSystemParams() { var promises = []; promises.push(loadIsUserTokenAccessEnabled()); + promises.push(timeService.loadMaxDatapointsLimit()); return $q.all(promises); } diff --git a/ui/src/app/app.js b/ui/src/app/app.js index c8cdeb02b0..31b53a0d03 100644 --- a/ui/src/app/app.js +++ b/ui/src/app/app.js @@ -31,6 +31,7 @@ import 'angular-translate-interpolation-messageformat'; import 'md-color-picker'; import mdPickers from 'mdPickers'; import ngSanitize from 'angular-sanitize'; +import FBAngular from 'angular-fullscreen'; import vAccordion from 'v-accordion'; import ngAnimate from 'angular-animate'; import 'angular-websocket'; @@ -51,6 +52,21 @@ import react from 'ngreact'; import '@flowjs/ng-flow/dist/ng-flow-standalone.min'; import 'ngFlowchart/dist/ngFlowchart'; +import 'typeface-roboto'; +import 'font-awesome/css/font-awesome.min.css'; +import 'angular-material/angular-material.min.css'; +import 'angular-material-icons/angular-material-icons.css'; +import 'angular-gridster/dist/angular-gridster.min.css'; +import 'v-accordion/dist/v-accordion.min.css'; +import 'md-color-picker/dist/mdColorPicker.min.css'; +import 'mdPickers/dist/mdPickers.min.css'; +import 'angular-hotkeys/build/hotkeys.min.css'; +import 'angular-carousel/dist/angular-carousel.min.css'; +import 'angular-material-expansion-panel/dist/md-expansion-panel.min.css'; +import 'ngFlowchart/dist/flowchart.css'; +import '../scss/main.scss'; + +import thingsboardThirdpartyFix from './common/thirdparty-fix'; import thingsboardTranslateHandler from './locale/translate-handler'; import thingsboardLogin from './login'; import thingsboardDialogs from './components/datakey-config-dialog.controller'; @@ -67,6 +83,7 @@ import thingsboardClipboard from './services/clipboard.service'; import thingsboardHome from './layout'; import thingsboardApiLogin from './api/login.service'; import thingsboardApiDevice from './api/device.service'; +import thingsboardApiEntityView from './api/entity-view.service'; import thingsboardApiUser from './api/user.service'; import thingsboardApiEntityRelation from './api/entity-relation.service'; import thingsboardApiAsset from './api/asset.service'; @@ -77,20 +94,6 @@ import thingsboardApiAuditLog from './api/audit-log.service'; import thingsboardApiComponentDescriptor from './api/component-descriptor.service'; import thingsboardApiRuleChain from './api/rule-chain.service'; -import 'typeface-roboto'; -import 'font-awesome/css/font-awesome.min.css'; -import 'angular-material/angular-material.min.css'; -import 'angular-material-icons/angular-material-icons.css'; -import 'angular-gridster/dist/angular-gridster.min.css'; -import 'v-accordion/dist/v-accordion.min.css'; -import 'md-color-picker/dist/mdColorPicker.min.css'; -import 'mdPickers/dist/mdPickers.min.css'; -import 'angular-hotkeys/build/hotkeys.min.css'; -import 'angular-carousel/dist/angular-carousel.min.css'; -import 'angular-material-expansion-panel/dist/md-expansion-panel.min.css'; -import 'ngFlowchart/dist/flowchart.css'; -import '../scss/main.scss'; - import AppConfig from './app.config'; import GlobalInterceptor from './global-interceptor.service'; import AppRun from './app.run'; @@ -104,6 +107,7 @@ angular.module('thingsboard', [ 'mdColorPicker', mdPickers, ngSanitize, + FBAngular.name, vAccordion, ngAnimate, 'ngWebSocket', @@ -117,6 +121,7 @@ angular.module('thingsboard', [ react.name, 'flow', 'flowchart', + thingsboardThirdpartyFix, thingsboardTranslateHandler, thingsboardLogin, thingsboardDialogs, @@ -133,6 +138,7 @@ angular.module('thingsboard', [ thingsboardHome, thingsboardApiLogin, thingsboardApiDevice, + thingsboardApiEntityView, thingsboardApiUser, thingsboardApiEntityRelation, thingsboardApiAsset, diff --git a/ui/src/app/audit/audit-log-row.directive.js b/ui/src/app/audit/audit-log-row.directive.js index 2c2e170690..f13a0d27c8 100644 --- a/ui/src/app/audit/audit-log-row.directive.js +++ b/ui/src/app/audit/audit-log-row.directive.js @@ -48,7 +48,7 @@ export default function AuditLogRowDirective($compile, $templateCache, types, $m parent: angular.element($document[0].body), targetEvent: $event, fullscreen: true, - skipHide: true, + multiple: true, onShowing: function(scope, element) { onShowingCallback.onShowing(scope, element); } diff --git a/ui/src/app/common/thirdparty-fix.js b/ui/src/app/common/thirdparty-fix.js new file mode 100644 index 0000000000..af4222c140 --- /dev/null +++ b/ui/src/app/common/thirdparty-fix.js @@ -0,0 +1,459 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import tinycolor from 'tinycolor2'; +import moment from 'moment'; + +export default angular.module('thingsboard.thirdpartyFix', []) + .factory('Fullscreen', Fullscreen) + .factory('$mdColorPicker', mdColorPicker) + .provider('$mdpDatePicker', mdpDatePicker) + .provider('$mdpTimePicker', mdpTimePicker) + .name; + +/*@ngInject*/ +function Fullscreen($document, $rootScope) { + + /* eslint-disable */ + + var document = $document[0]; + + // ensure ALLOW_KEYBOARD_INPUT is available and enabled + var isKeyboardAvailbleOnFullScreen = (typeof Element !== 'undefined' && 'ALLOW_KEYBOARD_INPUT' in Element) && Element.ALLOW_KEYBOARD_INPUT; + + var emitter = $rootScope.$new(); + + // listen event on document instead of element to avoid firefox limitation + // see https://developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/Using_full_screen_mode + $document.on('fullscreenchange webkitfullscreenchange mozfullscreenchange MSFullscreenChange', function(){ + emitter.$emit('FBFullscreen.change', serviceInstance.isEnabled()); + }); + + var serviceInstance = { + $on: angular.bind(emitter, emitter.$on), + all: function() { + serviceInstance.enable( document.documentElement ); + }, + enable: function(element) { + if(element.requestFullScreen) { + element.requestFullScreen(); + } else if(element.mozRequestFullScreen) { + element.mozRequestFullScreen(); + } else if(element.webkitRequestFullscreen) { + // Safari temporary fix + //if (/Version\/[\d]{1,2}(\.[\d]{1,2}){1}(\.(\d){1,2}){0,1} Safari/.test(navigator.userAgent)) { + if (/Safari/.test(navigator.userAgent)) { + element.webkitRequestFullscreen(); + } else { + element.webkitRequestFullscreen(isKeyboardAvailbleOnFullScreen); + } + } else if (element.msRequestFullscreen) { + element.msRequestFullscreen(); + } + }, + cancel: function() { + if(document.cancelFullScreen) { + document.cancelFullScreen(); + } else if(document.mozCancelFullScreen) { + document.mozCancelFullScreen(); + } else if(document.webkitExitFullscreen) { + document.webkitExitFullscreen(); + } else if (document.msExitFullscreen) { + document.msExitFullscreen(); + } + }, + isEnabled: function(){ + var fullscreenElement = document.fullscreenElement || document.mozFullScreenElement || document.webkitFullscreenElement || document.msFullscreenElement; + return fullscreenElement ? true : false; + }, + toggleAll: function(){ + serviceInstance.isEnabled() ? serviceInstance.cancel() : serviceInstance.all(); + }, + isSupported: function(){ + var docElm = document.documentElement; + var requestFullscreen = docElm.requestFullScreen || docElm.mozRequestFullScreen || docElm.webkitRequestFullscreen || docElm.msRequestFullscreen; + return requestFullscreen ? true : false; + } + }; + + /* eslint-enable */ + + return serviceInstance; +} + +/*@ngInject*/ +function mdColorPicker($q, $mdDialog, mdColorPickerHistory) { + var dialog; + + /* eslint-disable angular/definedundefined */ + + return { + show: function (options) + { + if ( options === undefined ) { + options = {}; + } + //console.log( 'DIALOG OPTIONS', options ); + // Defaults + // Dialog Properties + options.hasBackdrop = options.hasBackdrop === undefined ? true : options.hasBackdrop; + options.clickOutsideToClose = options.clickOutsideToClose === undefined ? true : options.clickOutsideToClose; + options.defaultValue = options.defaultValue === undefined ? '#FFFFFF' : options.defaultValue; + options.focusOnOpen = options.focusOnOpen === undefined ? false : options.focusOnOpen; + options.preserveScope = options.preserveScope === undefined ? true : options.preserveScope; + if (options.skipHide !== undefined) { + options.multiple = options.skipHide; + } + if (options.multiple === undefined) { + options.multiple = true; + } + + // mdColorPicker Properties + options.mdColorAlphaChannel = options.mdColorAlphaChannel === undefined ? false : options.mdColorAlphaChannel; + options.mdColorSpectrum = options.mdColorSpectrum === undefined ? true : options.mdColorSpectrum; + options.mdColorSliders = options.mdColorSliders === undefined ? true : options.mdColorSliders; + options.mdColorGenericPalette = options.mdColorGenericPalette === undefined ? true : options.mdColorGenericPalette; + options.mdColorMaterialPalette = options.mdColorMaterialPalette === undefined ? true : options.mdColorMaterialPalette; + options.mdColorHistory = options.mdColorHistory === undefined ? true : options.mdColorHistory; + + + dialog = $mdDialog.show({ + templateUrl: 'mdColorPickerDialog.tpl.html', + hasBackdrop: options.hasBackdrop, + clickOutsideToClose: options.clickOutsideToClose, + + controller: ['$scope', 'options', function( $scope, options ) { + //console.log( "DIALOG CONTROLLER OPEN", Date.now() - dateClick ); + $scope.close = function close() + { + $mdDialog.cancel(); + }; + $scope.ok = function ok() + { + $mdDialog.hide( $scope.value ); + }; + $scope.hide = $scope.ok; + + + + $scope.value = options.value; + $scope.default = options.defaultValue; + $scope.random = options.random; + + $scope.mdColorAlphaChannel = options.mdColorAlphaChannel; + $scope.mdColorSpectrum = options.mdColorSpectrum; + $scope.mdColorSliders = options.mdColorSliders; + $scope.mdColorGenericPalette = options.mdColorGenericPalette; + $scope.mdColorMaterialPalette = options.mdColorMaterialPalette; + $scope.mdColorHistory = options.mdColorHistory; + $scope.mdColorDefaultTab = options.mdColorDefaultTab; + + }], + + locals: { + options: options, + }, + preserveScope: options.preserveScope, + multiple: options.multiple, + + targetEvent: options.$event, + focusOnOpen: options.focusOnOpen, + autoWrap: false, + onShowing: function() { + // console.log( "DIALOG OPEN START", Date.now() - dateClick ); + }, + onComplete: function() { + // console.log( "DIALOG OPEN COMPLETE", Date.now() - dateClick ); + } + }); + + dialog.then(function (value) { + mdColorPickerHistory.add(new tinycolor(value)); + }, function () { }); + + return dialog; + }, + hide: function() { + return dialog.hide(); + }, + cancel: function() { + return dialog.cancel(); + } + }; + + /* eslint-enable angular/definedundefined */ +} + +function DatePickerCtrl($scope, $mdDialog, $mdMedia, $timeout, currentDate, options) { + var self = this; + + this.date = moment(currentDate); + this.minDate = options.minDate && moment(options.minDate).isValid() ? moment(options.minDate) : null; + this.maxDate = options.maxDate && moment(options.maxDate).isValid() ? moment(options.maxDate) : null; + this.displayFormat = options.displayFormat || "ddd, MMM DD"; + this.dateFilter = angular.isFunction(options.dateFilter) ? options.dateFilter : null; + this.selectingYear = false; + + // validate min and max date + if (this.minDate && this.maxDate) { + if (this.maxDate.isBefore(this.minDate)) { + this.maxDate = moment(this.minDate).add(1, 'days'); + } + } + + if (this.date) { + // check min date + if (this.minDate && this.date.isBefore(this.minDate)) { + this.date = moment(this.minDate); + } + + // check max date + if (this.maxDate && this.date.isAfter(this.maxDate)) { + this.date = moment(this.maxDate); + } + } + + this.yearItems = { + currentIndex_: 0, + PAGE_SIZE: 5, + START: (self.minDate ? self.minDate.year() : 1900), + END: (self.maxDate ? self.maxDate.year() : 0), + getItemAtIndex: function(index) { + if(this.currentIndex_ < index) + this.currentIndex_ = index; + + return this.START + index; + }, + getLength: function() { + return Math.min( + this.currentIndex_ + Math.floor(this.PAGE_SIZE / 2), + Math.abs(this.START - this.END) + 1 + ); + } + }; + + $scope.$mdMedia = $mdMedia; + $scope.year = this.date.year(); + + this.selectYear = function(year) { + self.date.year(year); + $scope.year = year; + self.selectingYear = false; + self.animate(); + }; + + this.showYear = function() { + self.yearTopIndex = (self.date.year() - self.yearItems.START) + Math.floor(self.yearItems.PAGE_SIZE / 2); + self.yearItems.currentIndex_ = (self.date.year() - self.yearItems.START) + 1; + self.selectingYear = true; + }; + + this.showCalendar = function() { + self.selectingYear = false; + }; + + this.cancel = function() { + $mdDialog.cancel(); + }; + + this.confirm = function() { + var date = this.date; + + if (this.minDate && this.date.isBefore(this.minDate)) { + date = moment(this.minDate); + } + + if (this.maxDate && this.date.isAfter(this.maxDate)) { + date = moment(this.maxDate); + } + + $mdDialog.hide(date.toDate()); + }; + + this.animate = function() { + self.animating = true; + $timeout(angular.noop).then(function() { + self.animating = false; + }) + }; +} + +/*@ngInject*/ +function mdpDatePicker() { + var LABEL_OK = "OK", + LABEL_CANCEL = "Cancel", + DISPLAY_FORMAT = "ddd, MMM DD"; + + this.setDisplayFormat = function(format) { + DISPLAY_FORMAT = format; + }; + + this.setOKButtonLabel = function(label) { + LABEL_OK = label; + }; + + this.setCancelButtonLabel = function(label) { + LABEL_CANCEL = label; + }; + + /*@ngInject*/ + this.$get = function($mdDialog) { + var datePicker = function(currentDate, options) { + if (!angular.isDate(currentDate)) currentDate = Date.now(); + if (!angular.isObject(options)) options = {}; + + options.displayFormat = DISPLAY_FORMAT; + + return $mdDialog.show({ + controller: ['$scope', '$mdDialog', '$mdMedia', '$timeout', 'currentDate', 'options', DatePickerCtrl], + controllerAs: 'datepicker', + clickOutsideToClose: true, + template: '' + + '' + + '
' + + '' + + '{{ datepicker.date.format(\'YYYY\') }}' + + '{{ datepicker.date.format(datepicker.displayFormat) }} ' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + + '{{ item }}' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '' + LABEL_CANCEL + '' + + '' + LABEL_OK + '' + + '' + + '
' + + '
' + + '
', + targetEvent: options.targetEvent, + locals: { + currentDate: currentDate, + options: options + }, + multiple: true + }); + }; + + return datePicker; + }; + +} + +function TimePickerCtrl($scope, $mdDialog, time, autoSwitch, $mdMedia) { + var self = this; + this.VIEW_HOURS = 1; + this.VIEW_MINUTES = 2; + this.currentView = this.VIEW_HOURS; + this.time = moment(time); + this.autoSwitch = !!autoSwitch; + + this.clockHours = parseInt(this.time.format("h")); + this.clockMinutes = parseInt(this.time.minutes()); + + $scope.$mdMedia = $mdMedia; + + this.switchView = function() { + self.currentView = self.currentView == self.VIEW_HOURS ? self.VIEW_MINUTES : self.VIEW_HOURS; + }; + + this.setAM = function() { + if(self.time.hours() >= 12) + self.time.hour(self.time.hour() - 12); + }; + + this.setPM = function() { + if(self.time.hours() < 12) + self.time.hour(self.time.hour() + 12); + }; + + this.cancel = function() { + $mdDialog.cancel(); + }; + + this.confirm = function() { + $mdDialog.hide(this.time.toDate()); + }; +} + +/*@ngInject*/ +function mdpTimePicker() { + var LABEL_OK = "OK", + LABEL_CANCEL = "Cancel"; + + this.setOKButtonLabel = function(label) { + LABEL_OK = label; + }; + + this.setCancelButtonLabel = function(label) { + LABEL_CANCEL = label; + }; + + /*@ngInject*/ + this.$get = function($mdDialog) { + var timePicker = function(time, options) { + if(!angular.isDate(time)) time = Date.now(); + if (!angular.isObject(options)) options = {}; + + return $mdDialog.show({ + controller: ['$scope', '$mdDialog', 'time', 'autoSwitch', '$mdMedia', TimePickerCtrl], + controllerAs: 'timepicker', + clickOutsideToClose: true, + template: '' + + '' + + '' + + '
' + + '{{ timepicker.time.format("h") }}:' + + '{{ timepicker.time.format("mm") }}' + + '
' + + '
' + + 'AM' + + 'PM' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + + '' + + '' + + '' + LABEL_CANCEL + '' + + '' + LABEL_OK + '' + + '' + + '
' + + '
' + + '
', + targetEvent: options.targetEvent, + locals: { + time: time, + autoSwitch: options.autoSwitch + }, + multiple: true + }); + }; + + return timePicker; + }; +} diff --git a/ui/src/app/common/types.constant.js b/ui/src/app/common/types.constant.js index 1e34577119..506ef1d9b6 100644 --- a/ui/src/app/common/types.constant.js +++ b/ui/src/app/common/types.constant.js @@ -253,6 +253,10 @@ export default angular.module('thingsboard.types', []) value: 'deviceType', name: 'alias.filter-type-device-type' }, + entityViewType: { + value: 'entityViewType', + name: 'alias.filter-type-entity-view-type' + }, relationsQuery: { value: 'relationsQuery', name: 'alias.filter-type-relations-query' @@ -264,6 +268,10 @@ export default angular.module('thingsboard.types', []) deviceSearchQuery: { value: 'deviceSearchQuery', name: 'alias.filter-type-device-search-query' + }, + entityViewSearchQuery: { + value: 'entityViewSearchQuery', + name: 'alias.filter-type-entity-view-search-query' } }, position: { @@ -327,7 +335,8 @@ export default angular.module('thingsboard.types', []) dashboard: "DASHBOARD", alarm: "ALARM", rulechain: "RULE_CHAIN", - rulenode: "RULE_NODE" + rulenode: "RULE_NODE", + entityView: "ENTITY_VIEW" }, aliasEntityType: { current_customer: "CURRENT_CUSTOMER" @@ -345,6 +354,12 @@ export default angular.module('thingsboard.types', []) list: 'entity.list-of-assets', nameStartsWith: 'entity.asset-name-starts-with' }, + "ENTITY_VIEW": { + type: 'entity.type-entity-view', + typePlural: 'entity.type-entity-views', + list: 'entity.list-of-entity-views', + nameStartsWith: 'entity.entity-view-name-starts-with' + }, "TENANT": { type: 'entity.type-tenant', typePlural: 'entity.type-tenants', diff --git a/ui/src/app/components/dashboard.scss b/ui/src/app/components/dashboard.scss index e0a9afd54e..b4a696a85c 100644 --- a/ui/src/app/components/dashboard.scss +++ b/ui/src/app/components/dashboard.scss @@ -22,7 +22,7 @@ div.tb-widget { overflow: hidden; outline: none; - @include transition(all .2s ease-in-out); + transition: all .2s ease-in-out; .tb-widget-title { max-height: 60px; @@ -99,7 +99,7 @@ md-content.tb-dashboard-content { outline: none; .gridster-item { - @include transition(none); + transition: none; } } diff --git a/ui/src/app/components/datakey-config.tpl.html b/ui/src/app/components/datakey-config.tpl.html index f9126e6f82..ff94f1a955 100644 --- a/ui/src/app/components/datakey-config.tpl.html +++ b/ui/src/app/components/datakey-config.tpl.html @@ -75,9 +75,16 @@ + \ No newline at end of file diff --git a/ui/src/app/components/datasource-entity.directive.js b/ui/src/app/components/datasource-entity.directive.js index b02a30d206..6bc1932be6 100644 --- a/ui/src/app/components/datasource-entity.directive.js +++ b/ui/src/app/components/datasource-entity.directive.js @@ -186,7 +186,7 @@ function DatasourceEntity($compile, $templateCache, $q, $mdDialog, $window, $doc random: tinycolor.random(), clickOutsideToClose: false, hasBackdrop: false, - skipHide: true, + multiple: true, preserveScope: false, mdColorAlphaChannel: true, @@ -220,7 +220,7 @@ function DatasourceEntity($compile, $templateCache, $q, $mdDialog, $window, $doc parent: angular.element($document[0].body), fullscreen: true, targetEvent: event, - skipHide: true, + multiple: true, onComplete: function () { var w = angular.element($window); w.triggerHandler('resize'); diff --git a/ui/src/app/components/datasource-entity.tpl.html b/ui/src/app/components/datasource-entity.tpl.html index db6fd3be96..8f257874c5 100644 --- a/ui/src/app/components/datasource-entity.tpl.html +++ b/ui/src/app/components/datasource-entity.tpl.html @@ -26,7 +26,6 @@
@@ -78,7 +77,6 @@ diff --git a/ui/src/app/components/datasource-func.directive.js b/ui/src/app/components/datasource-func.directive.js index 7515b61cc7..982685ffe1 100644 --- a/ui/src/app/components/datasource-func.directive.js +++ b/ui/src/app/components/datasource-func.directive.js @@ -139,7 +139,7 @@ function DatasourceFunc($compile, $templateCache, $mdDialog, $window, $document, random: tinycolor.random(), clickOutsideToClose: false, hasBackdrop: false, - skipHide: true, + multiple: true, preserveScope: false, mdColorAlphaChannel: true, @@ -173,7 +173,7 @@ function DatasourceFunc($compile, $templateCache, $mdDialog, $window, $document, parent: angular.element($document[0].body), fullscreen: true, targetEvent: event, - skipHide: true, + multiple: true, onComplete: function () { var w = angular.element($window); w.triggerHandler('resize'); diff --git a/ui/src/app/components/datetime-period.tpl.html b/ui/src/app/components/datetime-period.tpl.html index 605c3a8b20..7479576f96 100644 --- a/ui/src/app/components/datetime-period.tpl.html +++ b/ui/src/app/components/datetime-period.tpl.html @@ -18,14 +18,14 @@
+ > + mdp-auto-switch="true">
+ > + mdp-auto-switch="true">
\ No newline at end of file diff --git a/ui/src/app/components/grid.scss b/ui/src/app/components/grid.scss index 43138481a9..50e110bb0c 100644 --- a/ui/src/app/components/grid.scss +++ b/ui/src/app/components/grid.scss @@ -20,7 +20,7 @@ } .tb-card-item { - @include transition(all .2s ease-in-out); + transition: all .2s ease-in-out; md-card-content { max-height: 53px; @@ -46,7 +46,7 @@ .tb-current-item { opacity: .5; - @include transform(scale(1.05)); + transform: scale(1.05); } #tb-vertical-container { diff --git a/ui/src/app/components/json-form.directive.js b/ui/src/app/components/json-form.directive.js index b016f57d89..97b7bfb05f 100644 --- a/ui/src/app/components/json-form.directive.js +++ b/ui/src/app/components/json-form.directive.js @@ -96,7 +96,7 @@ function JsonForm($compile, $templateCache, $mdColorPicker) { random: tinycolor.random(), clickOutsideToClose: false, hasBackdrop: false, - skipHide: true, + multiple: true, preserveScope: false, mdColorAlphaChannel: true, diff --git a/ui/src/app/components/material-icon-select.directive.js b/ui/src/app/components/material-icon-select.directive.js index 67caff203f..f3efe63eba 100644 --- a/ui/src/app/components/material-icon-select.directive.js +++ b/ui/src/app/components/material-icon-select.directive.js @@ -67,7 +67,7 @@ function MaterialIconSelect($compile, $templateCache, $document, $mdDialog) { templateUrl: materialIconsDialogTemplate, parent: angular.element($document[0].body), locals: {icon: scope.icon}, - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (icon) { diff --git a/ui/src/app/components/menu-link.scss b/ui/src/app/components/menu-link.scss index 299797ce6b..be274d30df 100644 --- a/ui/src/app/components/menu-link.scss +++ b/ui/src/app/components/menu-link.scss @@ -16,7 +16,7 @@ @import "~compass-sass-mixins/lib/compass"; .md-button-toggle .md-toggle-icon.tb-toggled { - @include transform(rotateZ(180deg)); + transform: rotateZ(180deg); } .tb-menu-toggle-list.ng-hide { @@ -28,7 +28,7 @@ z-index: 1; overflow: hidden; - @include transition(.75s cubic-bezier(.35, 0, .25, 1)); + transition: .75s cubic-bezier(.35, 0, .25, 1); - @include transition-property(height); + transition-property: height; } diff --git a/ui/src/app/components/react/json-form.scss b/ui/src/app/components/react/json-form.scss index cca9d07a07..d324abef93 100644 --- a/ui/src/app/components/react/json-form.scss +++ b/ui/src/app/components/react/json-form.scss @@ -28,7 +28,7 @@ $input-label-float-scale: .75 !default; line-height: 12px; color: rgb(244, 67, 54); - @include transition(all 450ms cubic-bezier(.23, 1, .32, 1) 0ms); + transition: all 450ms cubic-bezier(.23, 1, .32, 1) 0ms; } .tb-container { @@ -77,13 +77,12 @@ label.tb-label { bottom: 100%; left: 0; color: rgba(0, 0, 0, .54); - transform-origin: left top; - -webkit-font-smoothing: antialiased; - @include transform(translate3d(0, $input-label-float-offset, 0) scale($input-label-float-scale)); + transition: transform $swift-ease-out-timing-function $swift-ease-out-duration, width $swift-ease-out-timing-function $swift-ease-out-duration; - @include transition(transform $swift-ease-out-timing-function $swift-ease-out-duration, - width $swift-ease-out-timing-function $swift-ease-out-duration); + transform: translate3d(0, $input-label-float-offset, 0) scale($input-label-float-scale); + transform-origin: left top; + -webkit-font-smoothing: antialiased; &.tb-focused { color: rgb(96, 125, 139); diff --git a/ui/src/app/components/side-menu.scss b/ui/src/app/components/side-menu.scss index 46da8db448..f6efcb963b 100644 --- a/ui/src/app/components/side-menu.scss +++ b/ui/src/app/components/side-menu.scss @@ -53,7 +53,7 @@ margin: auto 0 auto auto; background-size: 100% auto; - @include transition(transform .3s, ease-in-out); + transition: transform .3s, ease-in-out; } .tb-side-menu .md-button { diff --git a/ui/src/app/components/timewindow-panel.controller.js b/ui/src/app/components/timewindow-panel.controller.js index 3cf368b6db..d48355c3d4 100644 --- a/ui/src/app/components/timewindow-panel.controller.js +++ b/ui/src/app/components/timewindow-panel.controller.js @@ -31,6 +31,8 @@ export default function TimewindowPanelController(mdPanelRef, $scope, timeServic vm.maxRealtimeAggInterval = maxRealtimeAggInterval; vm.minHistoryAggInterval = minHistoryAggInterval; vm.maxHistoryAggInterval = maxHistoryAggInterval; + vm.minDatapointsLimit = minDatapointsLimit; + vm.maxDatapointsLimit = maxDatapointsLimit; if (vm.historyOnly) { vm.timewindow.selectedTab = 1; @@ -86,6 +88,14 @@ export default function TimewindowPanelController(mdPanelRef, $scope, timeServic return timeService.maxIntervalLimit(currentHistoryTimewindow()); } + function minDatapointsLimit () { + return timeService.getMinDatapointsLimit(); + } + + function maxDatapointsLimit () { + return timeService.getMaxDatapointsLimit(); + } + function currentHistoryTimewindow() { if (vm.timewindow.history.historyType === 0) { return vm.timewindow.history.timewindowMs; diff --git a/ui/src/app/components/timewindow-panel.tpl.html b/ui/src/app/components/timewindow-panel.tpl.html index 271ac4cc24..50888ac129 100644 --- a/ui/src/app/components/timewindow-panel.tpl.html +++ b/ui/src/app/components/timewindow-panel.tpl.html @@ -60,19 +60,22 @@ - + aggregation.limit - + - - + + - - diff --git a/ui/src/app/components/timewindow.directive.js b/ui/src/app/components/timewindow.directive.js index 651367179c..4a6b72e7f2 100644 --- a/ui/src/app/components/timewindow.directive.js +++ b/ui/src/app/components/timewindow.directive.js @@ -228,7 +228,7 @@ function Timewindow($compile, $templateCache, $filter, $mdPanel, $document, $mdM if (angular.isDefined(value.aggregation.type) && value.aggregation.type.length > 0) { model.aggregation.type = value.aggregation.type; } - model.aggregation.limit = value.aggregation.limit || timeService.avgAggregationLimit(); + model.aggregation.limit = value.aggregation.limit || Math.floor(timeService.getMaxDatapointsLimit() / 2); } } scope.updateDisplayValue(); diff --git a/ui/src/app/components/widget/action/manage-widget-actions.directive.js b/ui/src/app/components/widget/action/manage-widget-actions.directive.js index 88a37a876e..959720410e 100644 --- a/ui/src/app/components/widget/action/manage-widget-actions.directive.js +++ b/ui/src/app/components/widget/action/manage-widget-actions.directive.js @@ -164,7 +164,7 @@ function ManageWidgetActionsController($rootScope, $scope, $document, $mdDialog, .cancel($translate.instant('action.no')) .ok($translate.instant('action.yes')); - confirm._options.skipHide = true; + confirm._options.multiple = true; confirm._options.fullscreen = true; $mdDialog.show(confirm).then(function () { @@ -212,7 +212,7 @@ function ManageWidgetActionsController($rootScope, $scope, $document, $mdDialog, locals: {isAdd: isAdd, fetchDashboardStates: vm.fetchDashboardStates, actionSources: availableActionSources, widgetActions: vm.widgetActions, action: angular.copy(action)}, - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (action) { @@ -244,13 +244,18 @@ function ManageWidgetActionsController($rootScope, $scope, $document, $mdDialog, vm.widgetActions[actionSourceId] = targetActions; } if (prevActionId) { - var index = getActionIndex(prevActionId, vm.allActions); - if (index > -1) { - vm.allActions[index] = action; + const indexInTarget = getActionIndex(prevActionId, targetActions); + const indexInAllActions = getActionIndex(prevActionId, vm.allActions); + if (indexInTarget > -1) { + targetActions[indexInTarget] = widgetAction; + } else if (indexInAllActions > -1) { + const prevActionSourceId = vm.allActions[indexInAllActions].actionSourceId; + const index = getActionIndex(prevActionId,vm.widgetActions[prevActionSourceId]); + vm.widgetActions[prevActionSourceId].splice(index,1); + targetActions.push(widgetAction); } - index = getActionIndex(prevActionId, targetActions); - if (index > -1) { - targetActions[index] = widgetAction; + if (indexInAllActions > -1) { + vm.allActions[indexInAllActions] = action; } } else { vm.allActions.push(action); diff --git a/ui/src/app/dashboard/add-widget.controller.js b/ui/src/app/dashboard/add-widget.controller.js index caa69d6f98..fd665fd1a6 100644 --- a/ui/src/app/dashboard/add-widget.controller.js +++ b/ui/src/app/dashboard/add-widget.controller.js @@ -166,7 +166,7 @@ export default function AddWidgetController($scope, widgetService, entityService }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: event }).then(function (singleEntityAlias) { vm.dashboard.configuration.entityAliases[singleEntityAlias.id] = singleEntityAlias; diff --git a/ui/src/app/dashboard/dashboard-card.scss b/ui/src/app/dashboard/dashboard-card.scss index 8e6626c728..111d67a4de 100644 --- a/ui/src/app/dashboard/dashboard-card.scss +++ b/ui/src/app/dashboard/dashboard-card.scss @@ -16,12 +16,12 @@ .tb-dashboard-assigned-customers { display: block; - display: -webkit-box; + display: -webkit-box; /* stylelint-disable-line value-no-vendor-prefix */ height: 34px; margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 2; - -webkit-box-orient: vertical; + -webkit-box-orient: vertical; /* stylelint-disable-line property-no-vendor-prefix */ } diff --git a/ui/src/app/dashboard/dashboard-toolbar.scss b/ui/src/app/dashboard/dashboard-toolbar.scss index 80fd876ad3..c2476341e8 100644 --- a/ui/src/app/dashboard/dashboard-toolbar.scss +++ b/ui/src/app/dashboard/dashboard-toolbar.scss @@ -31,7 +31,7 @@ tb-dashboard-toolbar { &.md-fab { opacity: 1; - @include transition(opacity .3s cubic-bezier(.55,0,.55,.2)); + transition: opacity .3s cubic-bezier(.55, 0, .55, .2); .md-fab-toolbar-background { background-color: $primary-default !important; @@ -50,7 +50,7 @@ tb-dashboard-toolbar { line-height: 36px; opacity: .5; - @include transition(opacity .3s cubic-bezier(.55,0,.55,.2) .2s); + transition: opacity .3s cubic-bezier(.55, 0, .55, .2) .2s; md-icon { position: absolute; diff --git a/ui/src/app/dashboard/dashboard.controller.js b/ui/src/app/dashboard/dashboard.controller.js index 6df48df032..3f32e1b92c 100644 --- a/ui/src/app/dashboard/dashboard.controller.js +++ b/ui/src/app/dashboard/dashboard.controller.js @@ -463,7 +463,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget } }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (entityAliases) { @@ -488,7 +488,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget gridSettings: gridSettings }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (data) { @@ -510,7 +510,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget layouts: angular.copy(vm.dashboard.configuration.states[vm.dashboardCtx.state].layouts) }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (layouts) { @@ -531,7 +531,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget states: states }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (states) { @@ -873,7 +873,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget templateUrl: selectTargetLayoutTemplate, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event }).then( function success(layoutId) { @@ -941,7 +941,7 @@ export default function DashboardController(types, utils, dashboardUtils, widget }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: event, onComplete: function () { var w = angular.element($window); diff --git a/ui/src/app/dashboard/dashboard.scss b/ui/src/app/dashboard/dashboard.scss index 00a0653de7..715c4daafe 100644 --- a/ui/src/app/dashboard/dashboard.scss +++ b/ui/src/app/dashboard/dashboard.scss @@ -75,13 +75,13 @@ section.tb-dashboard-toolbar { &.tb-dashboard-toolbar-opened { right: 0; - // @include transition(right .3s cubic-bezier(.55,0,.55,.2)); + // transition: right .3s cubic-bezier(.55, 0, .55, .2); } &.tb-dashboard-toolbar-closed { right: 18px; - @include transition(right .3s cubic-bezier(.55,0,.55,.2) .2s); + transition: right .3s cubic-bezier(.55, 0, .55, .2) .2s; } } @@ -102,14 +102,14 @@ section.tb-dashboard-toolbar { margin-top: $toolbar-height; } - @include transition(margin-top .3s cubic-bezier(.55,0,.55,.2)); + transition: margin-top .3s cubic-bezier(.55, 0, .55, .2); } } &.tb-dashboard-toolbar-closed { margin-top: 0; - @include transition(margin-top .3s cubic-bezier(.55,0,.55,.2) .2s); + transition: margin-top .3s cubic-bezier(.55, 0, .55, .2) .2s; } .tb-dashboard-layouts { @@ -133,7 +133,7 @@ section.tb-powered-by-footer { position: absolute; right: 25px; bottom: 5px; - z-index: 3; + z-index: 30; pointer-events: none; span { diff --git a/ui/src/app/dashboard/dashboard.tpl.html b/ui/src/app/dashboard/dashboard.tpl.html index 829f174fe7..a1bb2688c0 100644 --- a/ui/src/app/dashboard/dashboard.tpl.html +++ b/ui/src/app/dashboard/dashboard.tpl.html @@ -74,6 +74,7 @@ settings @@ -146,7 +147,7 @@ ng-style="{minWidth: vm.rightLayoutWidth(), maxWidth: vm.rightLayoutWidth(), height: vm.rightLayoutHeight(), - zIndex: 12}" + zIndex: 25}" md-component-id="right-dashboard-layout" aria-label="Right dashboard layout" md-is-open="vm.rightLayoutOpened" diff --git a/ui/src/app/dashboard/edit-widget.directive.js b/ui/src/app/dashboard/edit-widget.directive.js index e6858b4227..f8aee69224 100644 --- a/ui/src/app/dashboard/edit-widget.directive.js +++ b/ui/src/app/dashboard/edit-widget.directive.js @@ -131,7 +131,7 @@ export default function EditWidgetDirective($compile, $templateCache, types, wid }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: event }).then(function (singleEntityAlias) { scope.dashboard.configuration.entityAliases[singleEntityAlias.id] = singleEntityAlias; diff --git a/ui/src/app/dashboard/layouts/manage-dashboard-layouts.controller.js b/ui/src/app/dashboard/layouts/manage-dashboard-layouts.controller.js index 5ce8a6ca23..828bd6f8a7 100644 --- a/ui/src/app/dashboard/layouts/manage-dashboard-layouts.controller.js +++ b/ui/src/app/dashboard/layouts/manage-dashboard-layouts.controller.js @@ -51,7 +51,7 @@ export default function ManageDashboardLayoutsController($scope, $mdDialog, $doc gridSettings: gridSettings }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (data) { diff --git a/ui/src/app/dashboard/states/manage-dashboard-states.controller.js b/ui/src/app/dashboard/states/manage-dashboard-states.controller.js index 98df466599..298b2ab5e7 100644 --- a/ui/src/app/dashboard/states/manage-dashboard-states.controller.js +++ b/ui/src/app/dashboard/states/manage-dashboard-states.controller.js @@ -111,7 +111,7 @@ export default function ManageDashboardStatesController($scope, $mdDialog, $filt templateUrl: dashboardStateDialogTemplate, parent: angular.element($document[0].body), locals: {isAdd: isAdd, allStates: vm.allStates, state: angular.copy(state)}, - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (state) { @@ -163,7 +163,7 @@ export default function ManageDashboardStatesController($scope, $mdDialog, $filt .cancel($translate.instant('action.no')) .ok($translate.instant('action.yes')); - confirm._options.skipHide = true; + confirm._options.multiple = true; confirm._options.fullscreen = true; $mdDialog.show(confirm).then(function () { diff --git a/ui/src/app/entity-view/add-entity-view.tpl.html b/ui/src/app/entity-view/add-entity-view.tpl.html new file mode 100644 index 0000000000..ebf1f1d341 --- /dev/null +++ b/ui/src/app/entity-view/add-entity-view.tpl.html @@ -0,0 +1,45 @@ + + +
+ +
+

entity-view.add

+ +
+ + + +
+
+ + + +
+ +
+
+ + + + {{ 'action.add' | translate }} + + {{ 'action.cancel' | translate }} + +
+
\ No newline at end of file diff --git a/ui/src/app/entity-view/add-entity-views-to-customer.controller.js b/ui/src/app/entity-view/add-entity-views-to-customer.controller.js new file mode 100644 index 0000000000..8e39546ffd --- /dev/null +++ b/ui/src/app/entity-view/add-entity-views-to-customer.controller.js @@ -0,0 +1,123 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/*@ngInject*/ +export default function AddEntityViewsToCustomerController(entityViewService, $mdDialog, $q, customerId, entityViews) { + + var vm = this; + + vm.entityViews = entityViews; + vm.searchText = ''; + + vm.assign = assign; + vm.cancel = cancel; + vm.hasData = hasData; + vm.noData = noData; + vm.searchEntityViewTextUpdated = searchEntityViewTextUpdated; + vm.toggleEntityViewSelection = toggleEntityViewSelection; + + vm.theEntityViews = { + getItemAtIndex: function (index) { + if (index > vm.entityViews.data.length) { + vm.theEntityViews.fetchMoreItems_(index); + return null; + } + var item = vm.entityViews.data[index]; + if (item) { + item.indexNumber = index + 1; + } + return item; + }, + + getLength: function () { + if (vm.entityViews.hasNext) { + return vm.entityViews.data.length + vm.entityViews.nextPageLink.limit; + } else { + return vm.entityViews.data.length; + } + }, + + fetchMoreItems_: function () { + if (vm.entityViews.hasNext && !vm.entityViews.pending) { + vm.entityViews.pending = true; + entityViewService.getTenantEntityViews(vm.entityViews.nextPageLink, false).then( + function success(entityViews) { + vm.entityViews.data = vm.entityViews.data.concat(entityViews.data); + vm.entityViews.nextPageLink = entityViews.nextPageLink; + vm.entityViews.hasNext = entityViews.hasNext; + if (vm.entityViews.hasNext) { + vm.entityViews.nextPageLink.limit = vm.entityViews.pageSize; + } + vm.entityViews.pending = false; + }, + function fail() { + vm.entityViews.hasNext = false; + vm.entityViews.pending = false; + }); + } + } + }; + + function cancel () { + $mdDialog.cancel(); + } + + function assign() { + var tasks = []; + for (var entityViewId in vm.entityViews.selections) { + tasks.push(entityViewService.assignEntityViewToCustomer(customerId, entityViewId)); + } + $q.all(tasks).then(function () { + $mdDialog.hide(); + }); + } + + function noData() { + return vm.entityViews.data.length == 0 && !vm.entityViews.hasNext; + } + + function hasData() { + return vm.entityViews.data.length > 0; + } + + function toggleEntityViewSelection($event, entityView) { + $event.stopPropagation(); + var selected = angular.isDefined(entityView.selected) && entityView.selected; + entityView.selected = !selected; + if (entityView.selected) { + vm.entityViews.selections[entityView.id.id] = true; + vm.entityViews.selectedCount++; + } else { + delete vm.entityViews.selections[entityView.id.id]; + vm.entityViews.selectedCount--; + } + } + + function searchEntityViewTextUpdated() { + vm.entityViews = { + pageSize: vm.entityViews.pageSize, + data: [], + nextPageLink: { + limit: vm.entityViews.pageSize, + textSearch: vm.searchText + }, + selections: {}, + selectedCount: 0, + hasNext: true, + pending: false + }; + } + +} diff --git a/ui/src/app/entity-view/add-entity-views-to-customer.tpl.html b/ui/src/app/entity-view/add-entity-views-to-customer.tpl.html new file mode 100644 index 0000000000..1149a1d8be --- /dev/null +++ b/ui/src/app/entity-view/add-entity-views-to-customer.tpl.html @@ -0,0 +1,77 @@ + + +
+ +
+

entity-view.assign-entity-view-to-customer

+ + + + +
+
+ + + +
+
+ entity-view.assign-entity-view-to-customer-text + + + + search + + + +
+ entity-view.no-entity-views-text + + + + + {{ entityView.name }} + + + +
+
+
+
+ + + + {{ 'action.assign' | translate }} + + {{ 'action.cancel' | + translate }} + + +
+
\ No newline at end of file diff --git a/ui/src/app/entity-view/assign-to-customer.controller.js b/ui/src/app/entity-view/assign-to-customer.controller.js new file mode 100644 index 0000000000..3e09ae6e4f --- /dev/null +++ b/ui/src/app/entity-view/assign-to-customer.controller.js @@ -0,0 +1,123 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/*@ngInject*/ +export default function AssignEntityViewToCustomerController(customerService, entityViewService, $mdDialog, $q, entityViewIds, customers) { + + var vm = this; + + vm.customers = customers; + vm.searchText = ''; + + vm.assign = assign; + vm.cancel = cancel; + vm.isCustomerSelected = isCustomerSelected; + vm.hasData = hasData; + vm.noData = noData; + vm.searchCustomerTextUpdated = searchCustomerTextUpdated; + vm.toggleCustomerSelection = toggleCustomerSelection; + + vm.theCustomers = { + getItemAtIndex: function (index) { + if (index > vm.customers.data.length) { + vm.theCustomers.fetchMoreItems_(index); + return null; + } + var item = vm.customers.data[index]; + if (item) { + item.indexNumber = index + 1; + } + return item; + }, + + getLength: function () { + if (vm.customers.hasNext) { + return vm.customers.data.length + vm.customers.nextPageLink.limit; + } else { + return vm.customers.data.length; + } + }, + + fetchMoreItems_: function () { + if (vm.customers.hasNext && !vm.customers.pending) { + vm.customers.pending = true; + customerService.getCustomers(vm.customers.nextPageLink).then( + function success(customers) { + vm.customers.data = vm.customers.data.concat(customers.data); + vm.customers.nextPageLink = customers.nextPageLink; + vm.customers.hasNext = customers.hasNext; + if (vm.customers.hasNext) { + vm.customers.nextPageLink.limit = vm.customers.pageSize; + } + vm.customers.pending = false; + }, + function fail() { + vm.customers.hasNext = false; + vm.customers.pending = false; + }); + } + } + }; + + function cancel() { + $mdDialog.cancel(); + } + + function assign() { + var tasks = []; + for (var i=0; i < entityViewIds.length;i++) { + tasks.push(entityViewService.assignEntityViewToCustomer(vm.customers.selection.id.id, entityViewIds[i])); + } + $q.all(tasks).then(function () { + $mdDialog.hide(); + }); + } + + function noData() { + return vm.customers.data.length == 0 && !vm.customers.hasNext; + } + + function hasData() { + return vm.customers.data.length > 0; + } + + function toggleCustomerSelection($event, customer) { + $event.stopPropagation(); + if (vm.isCustomerSelected(customer)) { + vm.customers.selection = null; + } else { + vm.customers.selection = customer; + } + } + + function isCustomerSelected(customer) { + return vm.customers.selection != null && customer && + customer.id.id === vm.customers.selection.id.id; + } + + function searchCustomerTextUpdated() { + vm.customers = { + pageSize: vm.customers.pageSize, + data: [], + nextPageLink: { + limit: vm.customers.pageSize, + textSearch: vm.searchText + }, + selection: null, + hasNext: true, + pending: false + }; + } +} diff --git a/ui/src/app/entity-view/assign-to-customer.tpl.html b/ui/src/app/entity-view/assign-to-customer.tpl.html new file mode 100644 index 0000000000..7c1fa2540e --- /dev/null +++ b/ui/src/app/entity-view/assign-to-customer.tpl.html @@ -0,0 +1,76 @@ + + +
+ +
+

entity-view.assign-entity-view-to-customer

+ + + + +
+
+ + + +
+
+ entity-view.assign-to-customer-text + + + + search + + + +
+ customer.no-customers-text + + + + + {{ customer.title }} + + + +
+
+
+
+ + + + {{ 'action.assign' | translate }} + + {{ 'action.cancel' | + translate }} + + +
+
\ No newline at end of file diff --git a/ui/src/app/entity-view/entity-view-card.tpl.html b/ui/src/app/entity-view/entity-view-card.tpl.html new file mode 100644 index 0000000000..1e90928540 --- /dev/null +++ b/ui/src/app/entity-view/entity-view-card.tpl.html @@ -0,0 +1,22 @@ + +
+
{{vm.item.type}}
+
{{vm.item.additionalInfo.description}}
+
{{'entity-view.assignedToCustomer' | translate}} '{{vm.item.assignedCustomer.title}}'
+
diff --git a/ui/src/app/entity-view/entity-view-fieldset.tpl.html b/ui/src/app/entity-view/entity-view-fieldset.tpl.html new file mode 100644 index 0000000000..766f8c82a1 --- /dev/null +++ b/ui/src/app/entity-view/entity-view-fieldset.tpl.html @@ -0,0 +1,212 @@ + +{{ 'entity-view.make-public' | translate }} +{{ 'entity-view.assign-to-customer' | translate }} +{{'entity-view.unassign-from-customer' | translate }} +{{ 'entity-view.delete' | translate }} + +
+ + + entity-view.copyId + +
+ + + + + + +
+ + + +
+
entity-view.name-required
+
+
+ + +
+ + + +
+ + + +
{{ 'entity-view.attributes-propagation' | translate }}
+ + +
+ + +
{{ 'entity-view.attributes-propagation' | translate }}
+ + +
+ +
entity-view.attributes-propagation-hint
+ + + + {{item}} + + + + + + {{item}} + + + + + + {{item}} + + +
+
+
+ + +
{{ 'entity-view.timeseries-data' | translate }}
+ + +
+ + +
{{ 'entity-view.timeseries-data' | translate }}
+ + +
+ +
entity-view.timeseries-data-hint
+ + + + {{item}} + + +
+
+
+
+
+ + +
+
+ + +
+ + + + +
+
diff --git a/ui/src/app/entity-view/entity-view.controller.js b/ui/src/app/entity-view/entity-view.controller.js new file mode 100644 index 0000000000..2c8c8ea036 --- /dev/null +++ b/ui/src/app/entity-view/entity-view.controller.js @@ -0,0 +1,483 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable import/no-unresolved, import/default */ + +import addEntityViewTemplate from './add-entity-view.tpl.html'; +import entityViewCard from './entity-view-card.tpl.html'; +import assignToCustomerTemplate from './assign-to-customer.tpl.html'; +import addEntityViewsToCustomerTemplate from './add-entity-views-to-customer.tpl.html'; + +/* eslint-enable import/no-unresolved, import/default */ + +/*@ngInject*/ +export function EntityViewCardController(types) { + + var vm = this; + + vm.types = types; + + vm.isAssignedToCustomer = function() { + if (vm.item && vm.item.customerId && vm.parentCtl.entityViewsScope === 'tenant' && + vm.item.customerId.id != vm.types.id.nullUid && !vm.item.assignedCustomer.isPublic) { + return true; + } + return false; + } + + vm.isPublic = function() { + if (vm.item && vm.item.assignedCustomer && vm.parentCtl.entityViewsScope === 'tenant' && vm.item.assignedCustomer.isPublic) { + return true; + } + return false; + } +} + + +/*@ngInject*/ +export function EntityViewController($rootScope, userService, entityViewService, customerService, $state, $stateParams, + $document, $mdDialog, $q, $translate, types) { + + var customerId = $stateParams.customerId; + + var entityViewActionsList = []; + + var entityViewGroupActionsList = []; + + var vm = this; + + vm.types = types; + + vm.entityViewGridConfig = { + deleteItemTitleFunc: deleteEntityViewTitle, + deleteItemContentFunc: deleteEntityViewText, + deleteItemsTitleFunc: deleteEntityViewsTitle, + deleteItemsActionTitleFunc: deleteEntityViewsActionTitle, + deleteItemsContentFunc: deleteEntityViewsText, + + saveItemFunc: saveEntityView, + + getItemTitleFunc: getEntityViewTitle, + + itemCardController: 'EntityViewCardController', + itemCardTemplateUrl: entityViewCard, + parentCtl: vm, + + actionsList: entityViewActionsList, + groupActionsList: entityViewGroupActionsList, + + onGridInited: gridInited, + + addItemTemplateUrl: addEntityViewTemplate, + + addItemText: function() { return $translate.instant('entity-view.add-entity-view-text') }, + noItemsText: function() { return $translate.instant('entity-view.no-entity-views-text') }, + itemDetailsText: function() { return $translate.instant('entity-view.entity-view-details') }, + isDetailsReadOnly: isCustomerUser, + isSelectionEnabled: function () { + return !isCustomerUser(); + } + }; + + if (angular.isDefined($stateParams.items) && $stateParams.items !== null) { + vm.entityViewGridConfig.items = $stateParams.items; + } + + if (angular.isDefined($stateParams.topIndex) && $stateParams.topIndex > 0) { + vm.entityViewGridConfig.topIndex = $stateParams.topIndex; + } + + vm.entityViewsScope = $state.$current.data.entityViewsType; + + vm.assignToCustomer = assignToCustomer; + vm.makePublic = makePublic; + vm.unassignFromCustomer = unassignFromCustomer; + + initController(); + + function initController() { + var fetchEntityViewsFunction = null; + var deleteEntityViewFunction = null; + var refreshEntityViewsParamsFunction = null; + + var user = userService.getCurrentUser(); + + if (user.authority === 'CUSTOMER_USER') { + vm.entityViewsScope = 'customer_user'; + customerId = user.customerId; + } + if (customerId) { + vm.customerEntityViewsTitle = $translate.instant('customer.entity-views'); + customerService.getShortCustomerInfo(customerId).then( + function success(info) { + if (info.isPublic) { + vm.customerEntityViewsTitle = $translate.instant('customer.public-entity-views'); + } + } + ); + } + + if (vm.entityViewsScope === 'tenant') { + fetchEntityViewsFunction = function (pageLink, entityViewType) { + return entityViewService.getTenantEntityViews(pageLink, true, null, entityViewType); + }; + deleteEntityViewFunction = function (entityViewId) { + return entityViewService.deleteEntityView(entityViewId); + }; + refreshEntityViewsParamsFunction = function() { + return {"topIndex": vm.topIndex}; + }; + + entityViewActionsList.push( + { + onAction: function ($event, item) { + assignToCustomer($event, [ item.id.id ]); + }, + name: function() { return $translate.instant('action.assign') }, + details: function() { return $translate.instant('entity-view.assign-to-customer') }, + icon: "assignment_ind", + isEnabled: function(entityView) { + return entityView && (!entityView.customerId || entityView.customerId.id === types.id.nullUid); + } + } + ); + + entityViewActionsList.push( + { + onAction: function ($event, item) { + unassignFromCustomer($event, item, false); + }, + name: function() { return $translate.instant('action.unassign') }, + details: function() { return $translate.instant('entity-view.unassign-from-customer') }, + icon: "assignment_return", + isEnabled: function(entityView) { + return entityView && entityView.customerId && entityView.customerId.id !== types.id.nullUid && !entityView.assignedCustomer.isPublic; + } + } + ); + + entityViewActionsList.push({ + onAction: function ($event, item) { + unassignFromCustomer($event, item, true); + }, + name: function() { return $translate.instant('action.make-private') }, + details: function() { return $translate.instant('entity-view.make-private') }, + icon: "reply", + isEnabled: function(entityView) { + return entityView && entityView.customerId && entityView.customerId.id !== types.id.nullUid && entityView.assignedCustomer.isPublic; + } + }); + + entityViewActionsList.push( + { + onAction: function ($event, item) { + vm.grid.deleteItem($event, item); + }, + name: function() { return $translate.instant('action.delete') }, + details: function() { return $translate.instant('entity-view.delete') }, + icon: "delete" + } + ); + + entityViewGroupActionsList.push( + { + onAction: function ($event, items) { + assignEntityViewsToCustomer($event, items); + }, + name: function() { return $translate.instant('entity-view.assign-entity-views') }, + details: function(selectedCount) { + return $translate.instant('entity-view.assign-entity-views-text', {count: selectedCount}, "messageformat"); + }, + icon: "assignment_ind" + } + ); + + entityViewGroupActionsList.push( + { + onAction: function ($event) { + vm.grid.deleteItems($event); + }, + name: function() { return $translate.instant('entity-view.delete-entity-views') }, + details: deleteEntityViewsActionTitle, + icon: "delete" + } + ); + + + + } else if (vm.entityViewsScope === 'customer' || vm.entityViewsScope === 'customer_user') { + fetchEntityViewsFunction = function (pageLink, entityViewType) { + return entityViewService.getCustomerEntityViews(customerId, pageLink, true, null, entityViewType); + }; + deleteEntityViewFunction = function (entityViewId) { + return entityViewService.unassignEntityViewFromCustomer(entityViewId); + }; + refreshEntityViewsParamsFunction = function () { + return {"customerId": customerId, "topIndex": vm.topIndex}; + }; + + if (vm.entityViewsScope === 'customer') { + entityViewActionsList.push( + { + onAction: function ($event, item) { + unassignFromCustomer($event, item, false); + }, + name: function() { return $translate.instant('action.unassign') }, + details: function() { return $translate.instant('entity-view.unassign-from-customer') }, + icon: "assignment_return", + isEnabled: function(entityView) { + return entityView && !entityView.assignedCustomer.isPublic; + } + } + ); + + entityViewGroupActionsList.push( + { + onAction: function ($event, items) { + unassignEntityViewsFromCustomer($event, items); + }, + name: function() { return $translate.instant('entity-view.unassign-entity-views') }, + details: function(selectedCount) { + return $translate.instant('entity-view.unassign-entity-views-action-title', {count: selectedCount}, "messageformat"); + }, + icon: "assignment_return" + } + ); + + vm.entityViewGridConfig.addItemAction = { + onAction: function ($event) { + addEntityViewsToCustomer($event); + }, + name: function() { return $translate.instant('entity-view.assign-entity-views') }, + details: function() { return $translate.instant('entity-view.assign-new-entity-view') }, + icon: "add" + }; + + + } else if (vm.entityViewsScope === 'customer_user') { + vm.entityViewGridConfig.addItemAction = {}; + } + } + + vm.entityViewGridConfig.refreshParamsFunc = refreshEntityViewsParamsFunction; + vm.entityViewGridConfig.fetchItemsFunc = fetchEntityViewsFunction; + vm.entityViewGridConfig.deleteItemFunc = deleteEntityViewFunction; + + } + + function deleteEntityViewTitle(entityView) { + return $translate.instant('entity-view.delete-entity-view-title', {entityViewName: entityView.name}); + } + + function deleteEntityViewText() { + return $translate.instant('entity-view.delete-entity-view-text'); + } + + function deleteEntityViewsTitle(selectedCount) { + return $translate.instant('entity-view.delete-entity-views-title', {count: selectedCount}, 'messageformat'); + } + + function deleteEntityViewsActionTitle(selectedCount) { + return $translate.instant('entity-view.delete-entity-views-action-title', {count: selectedCount}, 'messageformat'); + } + + function deleteEntityViewsText () { + return $translate.instant('entity-view.delete-entity-views-text'); + } + + function gridInited(grid) { + vm.grid = grid; + } + + function getEntityViewTitle(entityView) { + return entityView ? entityView.name : ''; + } + + function saveEntityView(entityView) { + var deferred = $q.defer(); + entityViewService.saveEntityView(entityView).then( + function success(savedEntityView) { + $rootScope.$broadcast('entityViewSaved'); + var entityViews = [ savedEntityView ]; + customerService.applyAssignedCustomersInfo(entityViews).then( + function success(items) { + if (items && items.length == 1) { + deferred.resolve(items[0]); + } else { + deferred.reject(); + } + }, + function fail() { + deferred.reject(); + } + ); + }, + function fail() { + deferred.reject(); + } + ); + return deferred.promise; + } + + function isCustomerUser() { + return vm.entityViewsScope === 'customer_user'; + } + + function assignToCustomer($event, entityViewIds) { + if ($event) { + $event.stopPropagation(); + } + var pageSize = 10; + customerService.getCustomers({limit: pageSize, textSearch: ''}).then( + function success(_customers) { + var customers = { + pageSize: pageSize, + data: _customers.data, + nextPageLink: _customers.nextPageLink, + selection: null, + hasNext: _customers.hasNext, + pending: false + }; + if (customers.hasNext) { + customers.nextPageLink.limit = pageSize; + } + $mdDialog.show({ + controller: 'AssignEntityViewToCustomerController', + controllerAs: 'vm', + templateUrl: assignToCustomerTemplate, + locals: {entityViewIds: entityViewIds, customers: customers}, + parent: angular.element($document[0].body), + fullscreen: true, + targetEvent: $event + }).then(function () { + vm.grid.refreshList(); + }, function () { + }); + }, + function fail() { + }); + } + + function addEntityViewsToCustomer($event) { + if ($event) { + $event.stopPropagation(); + } + var pageSize = 10; + entityViewService.getTenantEntityViews({limit: pageSize, textSearch: ''}, false).then( + function success(_entityViews) { + var entityViews = { + pageSize: pageSize, + data: _entityViews.data, + nextPageLink: _entityViews.nextPageLink, + selections: {}, + selectedCount: 0, + hasNext: _entityViews.hasNext, + pending: false + }; + if (entityViews.hasNext) { + entityViews.nextPageLink.limit = pageSize; + } + $mdDialog.show({ + controller: 'AddEntityViewsToCustomerController', + controllerAs: 'vm', + templateUrl: addEntityViewsToCustomerTemplate, + locals: {customerId: customerId, entityViews: entityViews}, + parent: angular.element($document[0].body), + fullscreen: true, + targetEvent: $event + }).then(function () { + vm.grid.refreshList(); + }, function () { + }); + }, + function fail() { + }); + } + + function assignEntityViewsToCustomer($event, items) { + var entityViewIds = []; + for (var id in items.selections) { + entityViewIds.push(id); + } + assignToCustomer($event, entityViewIds); + } + + function unassignFromCustomer($event, entityView, isPublic) { + if ($event) { + $event.stopPropagation(); + } + var title; + var content; + var label; + if (isPublic) { + title = $translate.instant('entity-view.make-private-entity-view-title', {entityViewName: entityView.name}); + content = $translate.instant('entity-view.make-private-entity-view-text'); + label = $translate.instant('entity-view.make-private'); + } else { + title = $translate.instant('entity-view.unassign-entity-view-title', {entityViewName: entityView.name}); + content = $translate.instant('entity-view.unassign-entity-view-text'); + label = $translate.instant('entity-view.unassign-entity-view'); + } + var confirm = $mdDialog.confirm() + .targetEvent($event) + .title(title) + .htmlContent(content) + .ariaLabel(label) + .cancel($translate.instant('action.no')) + .ok($translate.instant('action.yes')); + $mdDialog.show(confirm).then(function () { + entityViewService.unassignEntityViewFromCustomer(entityView.id.id).then(function success() { + vm.grid.refreshList(); + }); + }); + } + + function unassignEntityViewsFromCustomer($event, items) { + var confirm = $mdDialog.confirm() + .targetEvent($event) + .title($translate.instant('entity-view.unassign-entity-views-title', {count: items.selectedCount}, 'messageformat')) + .htmlContent($translate.instant('entity-view.unassign-entity-views-text')) + .ariaLabel($translate.instant('entity-view.unassign-entity-view')) + .cancel($translate.instant('action.no')) + .ok($translate.instant('action.yes')); + $mdDialog.show(confirm).then(function () { + var tasks = []; + for (var id in items.selections) { + tasks.push(entityViewService.unassignEntityViewFromCustomer(id)); + } + $q.all(tasks).then(function () { + vm.grid.refreshList(); + }); + }); + } + + function makePublic($event, entityView) { + if ($event) { + $event.stopPropagation(); + } + var confirm = $mdDialog.confirm() + .targetEvent($event) + .title($translate.instant('entity-view.make-public-entity-view-title', {entityViewName: entityView.name})) + .htmlContent($translate.instant('entity-view.make-public-entity-view-text')) + .ariaLabel($translate.instant('entity-view.make-public')) + .cancel($translate.instant('action.no')) + .ok($translate.instant('action.yes')); + $mdDialog.show(confirm).then(function () { + entityViewService.makeEntityViewPublic(entityView.id.id).then(function success() { + vm.grid.refreshList(); + }); + }); + } +} diff --git a/ui/src/app/entity-view/entity-view.directive.js b/ui/src/app/entity-view/entity-view.directive.js new file mode 100644 index 0000000000..761930e615 --- /dev/null +++ b/ui/src/app/entity-view/entity-view.directive.js @@ -0,0 +1,151 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import './entity-view.scss'; + +/* eslint-disable import/no-unresolved, import/default */ + +import entityViewFieldsetTemplate from './entity-view-fieldset.tpl.html'; + +/* eslint-enable import/no-unresolved, import/default */ + +/*@ngInject*/ +export default function EntityViewDirective($q, $compile, $templateCache, $filter, toast, $translate, $mdConstant, $mdExpansionPanel, + types, clipboardService, entityViewService, customerService, entityService) { + var linker = function (scope, element) { + var template = $templateCache.get(entityViewFieldsetTemplate); + element.html(template); + + scope.attributesPanelId = (Math.random()*1000).toFixed(0); + scope.timeseriesPanelId = (Math.random()*1000).toFixed(0); + scope.$mdExpansionPanel = $mdExpansionPanel; + + scope.types = types; + scope.isAssignedToCustomer = false; + scope.isPublic = false; + scope.assignedCustomer = null; + + scope.allowedEntityTypes = [types.entityType.device, types.entityType.asset]; + + var semicolon = 186; + scope.separatorKeys = [$mdConstant.KEY_CODE.ENTER, $mdConstant.KEY_CODE.COMMA, semicolon]; + + scope.$watch('entityView', function(newVal) { + if (newVal) { + if (scope.entityView.customerId && scope.entityView.customerId.id !== types.id.nullUid) { + scope.isAssignedToCustomer = true; + customerService.getShortCustomerInfo(scope.entityView.customerId.id).then( + function success(customer) { + scope.assignedCustomer = customer; + scope.isPublic = customer.isPublic; + } + ); + } else { + scope.isAssignedToCustomer = false; + scope.isPublic = false; + scope.assignedCustomer = null; + } + if (scope.entityView.startTimeMs > 0) { + scope.startTimeMs = new Date(scope.entityView.startTimeMs); + } else { + scope.startTimeMs = null; + } + if (scope.entityView.endTimeMs > 0) { + scope.endTimeMs = new Date(scope.entityView.endTimeMs); + } else { + scope.endTimeMs = null; + } + if (!scope.entityView.keys) { + scope.entityView.keys = {}; + scope.entityView.keys.timeseries = []; + scope.entityView.keys.attributes = {}; + scope.entityView.keys.attributes.ss = []; + scope.entityView.keys.attributes.cs = []; + scope.entityView.keys.attributes.sh = []; + } + } + }); + + scope.dataKeysSearch = function (searchText, type) { + var deferred = $q.defer(); + entityService.getEntityKeys(scope.entityView.entityId.entityType, scope.entityView.entityId.id, searchText, type, {ignoreLoading: true}).then( + function success(keys) { + deferred.resolve(keys); + }, + function fail() { + deferred.resolve([]); + } + ); + return deferred.promise; + + }; + + scope.$watch('startTimeMs', function (newDate) { + if (newDate) { + if (newDate.getTime() > scope.maxStartTimeMs) { + scope.startTimeMs = angular.copy(scope.maxStartTimeMs); + } + } + updateMinMaxDates(); + }); + + scope.$watch('endTimeMs', function (newDate) { + if (newDate) { + if (newDate.getTime() < scope.minEndTimeMs) { + scope.endTimeMs = angular.copy(scope.minEndTimeMs); + } + } + updateMinMaxDates(); + }); + + function updateMinMaxDates() { + if (scope.entityView) { + if (scope.endTimeMs) { + scope.maxStartTimeMs = angular.copy(new Date(scope.endTimeMs.getTime())); + scope.entityView.endTimeMs = scope.endTimeMs.getTime(); + } else { + scope.entityView.endTimeMs = 0; + } + if (scope.startTimeMs) { + scope.minEndTimeMs = angular.copy(new Date(scope.startTimeMs.getTime())); + scope.entityView.startTimeMs = scope.startTimeMs.getTime(); + } else { + scope.entityView.startTimeMs = 0; + } + } + } + + scope.onEntityViewIdCopied = function() { + toast.showSuccess($translate.instant('entity-view.idCopiedMessage'), 750, angular.element(element).parent().parent(), 'bottom left'); + }; + + $compile(element.contents())(scope); + } + return { + restrict: "E", + link: linker, + scope: { + entityView: '=', + isEdit: '=', + entityViewScope: '=', + theForm: '=', + onAssignToCustomer: '&', + onMakePublic: '&', + onUnassignFromCustomer: '&', + onDeleteEntityView: '&' + } + }; +} diff --git a/ui/src/app/entity-view/entity-view.routes.js b/ui/src/app/entity-view/entity-view.routes.js new file mode 100644 index 0000000000..99e89dd908 --- /dev/null +++ b/ui/src/app/entity-view/entity-view.routes.js @@ -0,0 +1,72 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/* eslint-disable import/no-unresolved, import/default */ + +import entityViewsTemplate from './entity-views.tpl.html'; + +/* eslint-enable import/no-unresolved, import/default */ + +/*@ngInject*/ +export default function EntityViewRoutes($stateProvider, types) { + $stateProvider + .state('home.entityViews', { + url: '/entityViews', + params: {'topIndex': 0}, + module: 'private', + auth: ['TENANT_ADMIN', 'CUSTOMER_USER'], + views: { + "content@home": { + templateUrl: entityViewsTemplate, + controller: 'EntityViewController', + controllerAs: 'vm' + } + }, + data: { + entityViewsType: 'tenant', + searchEnabled: true, + searchByEntitySubtype: true, + searchEntityType: types.entityType.entityView, + pageTitle: 'entity-view.entity-views' + }, + ncyBreadcrumb: { + label: '{"icon": "view_quilt", "label": "entity-view.entity-views"}' + } + }) + .state('home.customers.entityViews', { + url: '/:customerId/entityViews', + params: {'topIndex': 0}, + module: 'private', + auth: ['TENANT_ADMIN'], + views: { + "content@home": { + templateUrl: entityViewsTemplate, + controllerAs: 'vm', + controller: 'EntityViewController' + } + }, + data: { + entityViewsType: 'customer', + searchEnabled: true, + searchByEntitySubtype: true, + searchEntityType: types.entityType.entityView, + pageTitle: 'customer.entity-views' + }, + ncyBreadcrumb: { + label: '{"icon": "view_quilt", "label": "{{ vm.customerEntityViewsTitle }}", "translate": "false"}' + } + }); + +} diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineError.java b/ui/src/app/entity-view/entity-view.scss similarity index 55% rename from common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineError.java rename to ui/src/app/entity-view/entity-view.scss index dcfde0fb52..7b16b4a85d 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/core/RuleEngineError.java +++ b/ui/src/app/entity-view/entity-view.scss @@ -13,31 +13,35 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.msg.core; -/** - * @author Andrew Shvayka - */ - -public enum RuleEngineError { - - QUEUE_PUT_TIMEOUT(true), SERVER_ERROR(true), TIMEOUT; +@import "../../scss/constants"; - private final boolean critical; +.tb-entity-view-panel-group { + .tb-panel-title { + min-width: 90px; + user-select: none; - RuleEngineError() { - this(false); + @media (min-width: $layout-breakpoint-sm) { + min-width: 180px; } - - RuleEngineError(boolean critical) { - this.critical = critical; - } - - public boolean isCritical() { - return critical; + } + + .tb-panel-prompt { + overflow: hidden; + font-size: 14px; + color: rgba(0, 0, 0, .87); + text-overflow: ellipsis; + white-space: nowrap; + } + + &.disabled { + .tb-panel-title, + .tb-panel-prompt { + color: rgba(0, 0, 0, .38); } + } - public int getPriority() { - return ordinal(); - } + md-icon.md-expansion-panel-icon { + margin-right: 0; + } } diff --git a/ui/src/app/entity-view/entity-views.tpl.html b/ui/src/app/entity-view/entity-views.tpl.html new file mode 100644 index 0000000000..50a0adb5b1 --- /dev/null +++ b/ui/src/app/entity-view/entity-views.tpl.html @@ -0,0 +1,75 @@ + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
diff --git a/ui/src/app/entity-view/index.js b/ui/src/app/entity-view/index.js new file mode 100644 index 0000000000..69f669e4cf --- /dev/null +++ b/ui/src/app/entity-view/index.js @@ -0,0 +1,41 @@ +/* + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import uiRouter from 'angular-ui-router'; +import thingsboardGrid from '../components/grid.directive'; +import thingsboardApiUser from '../api/user.service'; +import thingsboardApiEntityView from '../api/entity-view.service'; +import thingsboardApiCustomer from '../api/customer.service'; + +import EntityViewRoutes from './entity-view.routes'; +import {EntityViewController, EntityViewCardController} from './entity-view.controller'; +import AssignEntityViewToCustomerController from './assign-to-customer.controller'; +import AddEntityViewsToCustomerController from './add-entity-views-to-customer.controller'; +import EntityViewDirective from './entity-view.directive'; + +export default angular.module('thingsboard.entityView', [ + uiRouter, + thingsboardGrid, + thingsboardApiUser, + thingsboardApiEntityView, + thingsboardApiCustomer +]) + .config(EntityViewRoutes) + .controller('EntityViewController', EntityViewController) + .controller('EntityViewCardController', EntityViewCardController) + .controller('AssignEntityViewToCustomerController', AssignEntityViewToCustomerController) + .controller('AddEntityViewsToCustomerController', AddEntityViewsToCustomerController) + .directive('tbEntityView', EntityViewDirective) + .name; diff --git a/ui/src/app/entity/alias/entity-aliases.controller.js b/ui/src/app/entity/alias/entity-aliases.controller.js index 732f10f456..49f28c9122 100644 --- a/ui/src/app/entity/alias/entity-aliases.controller.js +++ b/ui/src/app/entity/alias/entity-aliases.controller.js @@ -126,7 +126,7 @@ export default function EntityAliasesController(utils, entityService, toast, $sc }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event }).then(function (alias) { if (isAdd) { @@ -158,7 +158,7 @@ export default function EntityAliasesController(utils, entityService, toast, $sc .ariaLabel($translate.instant('entity.unable-delete-entity-alias-title')) .ok($translate.instant('action.close')) .targetEvent($event); - alert._options.skipHide = true; + alert._options.multiple = true; alert._options.fullscreen = true; $mdDialog.show(alert); diff --git a/ui/src/app/entity/attribute/add-widget-to-dashboard-dialog.controller.js b/ui/src/app/entity/attribute/add-widget-to-dashboard-dialog.controller.js index 5346e038f8..f39593c2be 100644 --- a/ui/src/app/entity/attribute/add-widget-to-dashboard-dialog.controller.js +++ b/ui/src/app/entity/attribute/add-widget-to-dashboard-dialog.controller.js @@ -53,7 +53,7 @@ export default function AddWidgetToDashboardDialogController($scope, $mdDialog, states: states }, fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event }).then( function success(stateId) { @@ -81,7 +81,7 @@ export default function AddWidgetToDashboardDialogController($scope, $mdDialog, templateUrl: selectTargetLayoutTemplate, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event }).then( function success(layoutId) { diff --git a/ui/src/app/entity/attribute/attribute-table.directive.js b/ui/src/app/entity/attribute/attribute-table.directive.js index 00618541ce..3a7c164885 100644 --- a/ui/src/app/entity/attribute/attribute-table.directive.js +++ b/ui/src/app/entity/attribute/attribute-table.directive.js @@ -54,7 +54,7 @@ export default function AttributeTableDirective($compile, $templateCache, $rootS scope.entityType = attrs.entityType; - if (scope.entityType === types.entityType.device) { + if (scope.entityType === types.entityType.device || scope.entityType === types.entityType.entityView) { scope.attributeScopes = types.attributesScope; scope.attributeScopeSelectionReadonly = false; } else { diff --git a/ui/src/app/entity/entity-autocomplete.directive.js b/ui/src/app/entity/entity-autocomplete.directive.js index e46c614c33..8c1fed76f1 100644 --- a/ui/src/app/entity/entity-autocomplete.directive.js +++ b/ui/src/app/entity/entity-autocomplete.directive.js @@ -131,6 +131,12 @@ export default function EntityAutocomplete($compile, $templateCache, $q, $filter scope.noEntitiesMatchingText = 'device.no-devices-matching'; scope.entityRequiredText = 'device.device-required'; break; + case types.entityType.entityView: + scope.selectEntityText = 'entity-view.select-entity-view'; + scope.entityText = 'entity-view.entity-view'; + scope.noEntitiesMatchingText = 'entity-view.no-entity-views-matching'; + scope.entityRequiredText = 'entity-view.entity-view-required'; + break; case types.entityType.rulechain: scope.selectEntityText = 'rulechain.select-rulechain'; scope.entityText = 'rulechain.rulechain'; diff --git a/ui/src/app/entity/entity-filter-view.directive.js b/ui/src/app/entity/entity-filter-view.directive.js index 8bd34224a2..5b34107a35 100644 --- a/ui/src/app/entity/entity-filter-view.directive.js +++ b/ui/src/app/entity/entity-filter-view.directive.js @@ -77,6 +77,15 @@ export default function EntityFilterViewDirective($compile, $templateCache, $q, scope.filterDisplayValue = $translate.instant('alias.filter-type-device-type-description', {deviceType: deviceType}); } break; + case types.aliasFilterType.entityViewType.value: + var entityViewType = scope.filter.entityViewType; + prefix = scope.filter.entityViewNameFilter; + if (prefix && prefix.length) { + scope.filterDisplayValue = $translate.instant('alias.filter-type-entity-view-type-and-name-description', {entityViewType: entityViewType, prefix: prefix}); + } else { + scope.filterDisplayValue = $translate.instant('alias.filter-type-entity-view-type-description', {entityViewType: entityViewType}); + } + break; case types.aliasFilterType.relationsQuery.value: var rootEntityText; var directionText; @@ -134,6 +143,7 @@ export default function EntityFilterViewDirective($compile, $templateCache, $q, break; case types.aliasFilterType.assetSearchQuery.value: case types.aliasFilterType.deviceSearchQuery.value: + case types.aliasFilterType.entityViewSearchQuery.value: allEntitiesText = $translate.instant('alias.all-entities'); anyRelationText = $translate.instant('alias.any-relation'); if (scope.filter.rootStateEntity) { @@ -165,7 +175,7 @@ export default function EntityFilterViewDirective($compile, $templateCache, $q, scope.filterDisplayValue = $translate.instant('alias.filter-type-asset-search-query-description', translationValues ); - } else { + } else if (scope.filter.type == types.aliasFilterType.deviceSearchQuery.value) { var deviceTypesQuoted = []; scope.filter.deviceTypes.forEach(function(deviceType) { deviceTypesQuoted.push("'"+deviceType+"'"); @@ -175,6 +185,16 @@ export default function EntityFilterViewDirective($compile, $templateCache, $q, scope.filterDisplayValue = $translate.instant('alias.filter-type-device-search-query-description', translationValues ); + } else if (scope.filter.type == types.aliasFilterType.entityViewSearchQuery.value) { + var entityViewTypesQuoted = []; + scope.filter.entityViewTypes.forEach(function(entityViewType) { + entityViewTypesQuoted.push("'"+entityViewType+"'"); + }); + var entityViewTypesText = entityViewTypesQuoted.join(', '); + translationValues.entityViewTypes = entityViewTypesText; + scope.filterDisplayValue = $translate.instant('alias.filter-type-entity-view-search-query-description', + translationValues + ); } break; default: diff --git a/ui/src/app/entity/entity-filter.directive.js b/ui/src/app/entity/entity-filter.directive.js index 0c8f6468f0..38641fbc8b 100644 --- a/ui/src/app/entity/entity-filter.directive.js +++ b/ui/src/app/entity/entity-filter.directive.js @@ -69,9 +69,14 @@ export default function EntityFilterDirective($compile, $templateCache, $q, $doc filter.deviceType = null; filter.deviceNameFilter = ''; break; + case types.aliasFilterType.entityViewType.value: + filter.entityViewType = null; + filter.entityViewNameFilter = ''; + break; case types.aliasFilterType.relationsQuery.value: case types.aliasFilterType.assetSearchQuery.value: case types.aliasFilterType.deviceSearchQuery.value: + case types.aliasFilterType.entityViewSearchQuery.value: filter.rootStateEntity = false; filter.stateEntityParamName = null; filter.defaultStateEntity = null; @@ -86,6 +91,9 @@ export default function EntityFilterDirective($compile, $templateCache, $q, $doc } else if (filter.type === types.aliasFilterType.deviceSearchQuery.value) { filter.relationType = null; filter.deviceTypes = []; + } else if (filter.type === types.aliasFilterType.entityViewSearchQuery.value) { + filter.relationType = null; + filter.entityViewTypes = []; } break; } diff --git a/ui/src/app/entity/entity-filter.tpl.html b/ui/src/app/entity/entity-filter.tpl.html index f9aac3cb78..95193f2a64 100644 --- a/ui/src/app/entity/entity-filter.tpl.html +++ b/ui/src/app/entity/entity-filter.tpl.html @@ -112,6 +112,20 @@ aria-label="{{ 'device.name-starts-with' | translate }}">
+
+ + + + + + +
@@ -311,4 +325,73 @@ ng-model="filter.deviceTypes">
+
+ +
+ + + +
+
+ + +
+
+ + + + +
+ + + +
+
+
+ + + + + {{ ('relation.search-direction.' + direction) | translate}} + + + + + + + +
+
relation.relation-type
+ + +
entity-view.entity-view-types
+ + +
diff --git a/ui/src/app/entity/entity-subtype-autocomplete.directive.js b/ui/src/app/entity/entity-subtype-autocomplete.directive.js index 5812715ee3..4c79f9a92d 100644 --- a/ui/src/app/entity/entity-subtype-autocomplete.directive.js +++ b/ui/src/app/entity/entity-subtype-autocomplete.directive.js @@ -22,7 +22,7 @@ import entitySubtypeAutocompleteTemplate from './entity-subtype-autocomplete.tpl /* eslint-enable import/no-unresolved, import/default */ /*@ngInject*/ -export default function EntitySubtypeAutocomplete($compile, $templateCache, $q, $filter, assetService, deviceService, types) { +export default function EntitySubtypeAutocomplete($compile, $templateCache, $q, $filter, assetService, deviceService, entityViewService, types) { var linker = function (scope, element, attrs, ngModelCtrl) { var template = $templateCache.get(entitySubtypeAutocompleteTemplate); @@ -96,6 +96,8 @@ export default function EntitySubtypeAutocomplete($compile, $templateCache, $q, entitySubtypesPromise = assetService.getAssetTypes({ignoreLoading: true}); } else if (scope.entityType == types.entityType.device) { entitySubtypesPromise = deviceService.getDeviceTypes({ignoreLoading: true}); + } else if (scope.entityType == types.entityType.entityView) { + entitySubtypesPromise = entityViewService.getEntityViewTypes({ignoreLoading: true}); } if (entitySubtypesPromise) { entitySubtypesPromise.then( @@ -134,6 +136,13 @@ export default function EntitySubtypeAutocomplete($compile, $templateCache, $q, scope.$on('deviceSaved', function() { scope.entitySubtypes = null; }); + } else if (scope.entityType == types.entityType.entityView) { + scope.selectEntitySubtypeText = 'entity-view.select-entity-view-type'; + scope.entitySubtypeText = 'entity-view.entity-view-type'; + scope.entitySubtypeRequiredText = 'entity-view.entity-view-type-required'; + scope.$on('entityViewSaved', function() { + scope.entitySubtypes = null; + }); } } diff --git a/ui/src/app/entity/entity-subtype-list.directive.js b/ui/src/app/entity/entity-subtype-list.directive.js index d74a7b0523..43bb21f7a9 100644 --- a/ui/src/app/entity/entity-subtype-list.directive.js +++ b/ui/src/app/entity/entity-subtype-list.directive.js @@ -22,7 +22,7 @@ import entitySubtypeListTemplate from './entity-subtype-list.tpl.html'; import './entity-subtype-list.scss'; /*@ngInject*/ -export default function EntitySubtypeListDirective($compile, $templateCache, $q, $mdUtil, $translate, $filter, types, assetService, deviceService) { +export default function EntitySubtypeListDirective($compile, $templateCache, $q, $mdUtil, $translate, $filter, types, assetService, deviceService, entityViewService) { var linker = function (scope, element, attrs, ngModelCtrl) { @@ -47,6 +47,12 @@ export default function EntitySubtypeListDirective($compile, $templateCache, $q, scope.secondaryPlaceholder = '+' + $translate.instant('device.device-type'); scope.noSubtypesMathingText = 'device.no-device-types-matching'; scope.subtypeListEmptyText = 'device.device-type-list-empty'; + } else if (scope.entityType == types.entityType.entityView) { + scope.placeholder = scope.tbRequired ? $translate.instant('entity-view.enter-entity-view-type') + : $translate.instant('entity-view.any-entity-view'); + scope.secondaryPlaceholder = '+' + $translate.instant('entity-view.entity-view-type'); + scope.noSubtypesMathingText = 'entity-view.no-entity-view-types-matching'; + scope.subtypeListEmptyText = 'entity-view.entity-view-type-list-empty'; } scope.$watch('tbRequired', function () { @@ -97,6 +103,8 @@ export default function EntitySubtypeListDirective($compile, $templateCache, $q, entitySubtypesPromise = assetService.getAssetTypes({ignoreLoading: true}); } else if (scope.entityType == types.entityType.device) { entitySubtypesPromise = deviceService.getDeviceTypes({ignoreLoading: true}); + } else if (scope.entityType == types.entityType.entityView) { + entitySubtypesPromise = entityViewService.getEntityViewTypes({ignoreLoading: true}); } if (entitySubtypesPromise) { entitySubtypesPromise.then( diff --git a/ui/src/app/entity/entity-subtype-select.directive.js b/ui/src/app/entity/entity-subtype-select.directive.js index b86e944411..8508f15116 100644 --- a/ui/src/app/entity/entity-subtype-select.directive.js +++ b/ui/src/app/entity/entity-subtype-select.directive.js @@ -22,7 +22,7 @@ import entitySubtypeSelectTemplate from './entity-subtype-select.tpl.html'; /* eslint-enable import/no-unresolved, import/default */ /*@ngInject*/ -export default function EntitySubtypeSelect($compile, $templateCache, $translate, assetService, deviceService, types) { +export default function EntitySubtypeSelect($compile, $templateCache, $translate, assetService, deviceService, entityViewService, types) { var linker = function (scope, element, attrs, ngModelCtrl) { var template = $templateCache.get(entitySubtypeSelectTemplate); @@ -75,6 +75,8 @@ export default function EntitySubtypeSelect($compile, $templateCache, $translate entitySubtypesPromise = assetService.getAssetTypes({ignoreLoading: true}); } else if (scope.entityType == types.entityType.device) { entitySubtypesPromise = deviceService.getDeviceTypes({ignoreLoading: true}); + } else if (scope.entityType == types.entityType.entityView) { + entitySubtypesPromise = entityViewService.getEntityViewTypes({ignoreLoading: true}); } if (entitySubtypesPromise) { entitySubtypesPromise.then( @@ -100,6 +102,9 @@ export default function EntitySubtypeSelect($compile, $templateCache, $translate } else if (scope.entityType == types.entityType.device) { scope.entitySubtypeTitle = 'device.device-type'; scope.entitySubtypeRequiredText = 'device.device-type-required'; + } else if (scope.entityType == types.entityType.entityView) { + scope.entitySubtypeTitle = 'entity-view.entity-view-type'; + scope.entitySubtypeRequiredText = 'entity-view.entity-view-type-required'; } scope.entitySubtypes.length = 0; if (scope.entitySubtypesList && scope.entitySubtypesList.length) { @@ -116,6 +121,10 @@ export default function EntitySubtypeSelect($compile, $templateCache, $translate scope.$on('deviceSaved', function() { loadSubTypes(); }); + } else if (scope.entityType == types.entityType.entityView) { + scope.$on('entityViewSaved', function() { + loadSubTypes(); + }); } } } diff --git a/ui/src/app/entity/relation/relation-table.directive.js b/ui/src/app/entity/relation/relation-table.directive.js index 872042c2fa..d2586c4845 100644 --- a/ui/src/app/entity/relation/relation-table.directive.js +++ b/ui/src/app/entity/relation/relation-table.directive.js @@ -160,7 +160,7 @@ function RelationTableController($scope, $q, $mdDialog, $document, $translate, $ showingCallback: onShowingCallback}, targetEvent: $event, fullscreen: true, - skipHide: true, + multiple: true, onShowing: function(scope, element) { onShowingCallback.onShowing(scope, element); } diff --git a/ui/src/app/event/event-row.directive.js b/ui/src/app/event/event-row.directive.js index b808fb8fff..5f84187e2c 100644 --- a/ui/src/app/event/event-row.directive.js +++ b/ui/src/app/event/event-row.directive.js @@ -79,7 +79,7 @@ export default function EventRowDirective($compile, $templateCache, $mdDialog, $ parent: angular.element($document[0].body), fullscreen: true, targetEvent: $event, - skipHide: true, + multiple: true, onShowing: function(scope, element) { onShowingCallback.onShowing(scope, element); } diff --git a/ui/src/app/extension/extension-table.directive.js b/ui/src/app/extension/extension-table.directive.js index 18d281c0b0..73c5d93e80 100644 --- a/ui/src/app/extension/extension-table.directive.js +++ b/ui/src/app/extension/extension-table.directive.js @@ -208,7 +208,7 @@ function ExtensionTableController($scope, $filter, $document, $translate, $timeo bindToController: true, targetEvent: $event, fullscreen: true, - skipHide: true + multiple: true }).then(function() { reloadExtensions(); }, function () { diff --git a/ui/src/app/help/help-links.constant.js b/ui/src/app/help/help-links.constant.js index 458c118883..9e6bb524d0 100644 --- a/ui/src/app/help/help-links.constant.js +++ b/ui/src/app/help/help-links.constant.js @@ -96,6 +96,7 @@ export default angular.module('thingsboard.help', []) customers: helpBaseUrl + "/docs/user-guide/ui/customers", assets: helpBaseUrl + "/docs/user-guide/ui/assets", devices: helpBaseUrl + "/docs/user-guide/ui/devices", + entityViews: helpBaseUrl + "/docs/user-guide/ui/entity-views", dashboards: helpBaseUrl + "/docs/user-guide/ui/dashboards", users: helpBaseUrl + "/docs/user-guide/ui/users", widgetsBundles: helpBaseUrl + "/docs/user-guide/ui/widget-library#bundles", diff --git a/ui/src/app/import-export/import-export.service.js b/ui/src/app/import-export/import-export.service.js index d64441fdef..e08fd24d5a 100644 --- a/ui/src/app/import-export/import-export.service.js +++ b/ui/src/app/import-export/import-export.service.js @@ -721,7 +721,7 @@ export default function ImportExport($log, $translate, $q, $mdDialog, $document, } }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (updatedEntityAliases) { @@ -796,7 +796,7 @@ export default function ImportExport($log, $translate, $q, $mdDialog, $document, importFileLabel: importFileLabel }, parent: angular.element($document[0].body), - skipHide: true, + multiple: true, fullscreen: true, targetEvent: $event }).then(function (importData) { diff --git a/ui/src/app/layout/home.scss b/ui/src/app/layout/home.scss index 48c1553877..43b83297a9 100644 --- a/ui/src/app/layout/home.scss +++ b/ui/src/app/layout/home.scss @@ -42,7 +42,7 @@ border: none; opacity: .75; - @include transition(opacity .35s); + transition: opacity .35s; } a:hover, diff --git a/ui/src/app/layout/index.js b/ui/src/app/layout/index.js index 8f2958d9e3..7c7e870205 100644 --- a/ui/src/app/layout/index.js +++ b/ui/src/app/layout/index.js @@ -17,7 +17,6 @@ import './home.scss'; import uiRouter from 'angular-ui-router'; import ngSanitize from 'angular-sanitize'; -import FBAngular from 'angular-fullscreen'; import 'angular-breadcrumb'; import thingsboardMenu from '../services/menu.service'; @@ -48,6 +47,7 @@ import thingsboardAdmin from '../admin'; import thingsboardProfile from '../profile'; import thingsboardAsset from '../asset'; import thingsboardDevice from '../device'; +import thingsboardEntityView from '../entity-view'; import thingsboardWidgetLibrary from '../widget'; import thingsboardDashboard from '../dashboard'; import thingsboardRuleChain from '../rulechain'; @@ -62,7 +62,6 @@ import BreadcrumbIcon from './breadcrumb-icon.filter'; export default angular.module('thingsboard.home', [ uiRouter, ngSanitize, - FBAngular.name, 'ncy-angular-breadcrumb', thingsboardMenu, thingsboardHomeLinks, @@ -79,6 +78,7 @@ export default angular.module('thingsboard.home', [ thingsboardProfile, thingsboardAsset, thingsboardDevice, + thingsboardEntityView, thingsboardWidgetLibrary, thingsboardDashboard, thingsboardRuleChain, diff --git a/ui/src/app/locale/locale.constant-en_US.json b/ui/src/app/locale/locale.constant-en_US.json index 6321d63f46..30e1eb119b 100644 --- a/ui/src/app/locale/locale.constant-en_US.json +++ b/ui/src/app/locale/locale.constant-en_US.json @@ -133,8 +133,13 @@ "min-polling-interval-message": "At least 1 sec polling interval is allowed.", "aknowledge-alarms-title": "Acknowledge { count, plural, 1 {1 alarm} other {# alarms} }", "aknowledge-alarms-text": "Are you sure you want to acknowledge { count, plural, 1 {1 alarm} other {# alarms} }?", + "aknowledge-alarm-title": "Acknowledge Alarm", + "aknowledge-alarm-text": "Are you sure you want to acknowledge Alarm?", "clear-alarms-title": "Clear { count, plural, 1 {1 alarm} other {# alarms} }", - "clear-alarms-text": "Are you sure you want to clear { count, plural, 1 {1 alarm} other {# alarms} }?" + "clear-alarms-text": "Are you sure you want to clear { count, plural, 1 {1 alarm} other {# alarms} }?", + "clear-alarm-title": "Clear Alarm", + "clear-alarm-text": "Are you sure you want to clear Alarm?", + "alarm-status-filter": "Alarm Status Filter" }, "alias": { "add": "Add alias", @@ -153,12 +158,17 @@ "filter-type-device-type": "Device type", "filter-type-device-type-description": "Devices of type '{{deviceType}}'", "filter-type-device-type-and-name-description": "Devices of type '{{deviceType}}' and with name starting with '{{prefix}}'", + "filter-type-entity-view-type": "Entity View type", + "filter-type-entity-view-type-description": "Entity Views of type '{{entityView}}'", + "filter-type-entity-view-type-and-name-description": "Entity Views of type '{{entityView}}' and with name starting with '{{prefix}}'", "filter-type-relations-query": "Relations query", "filter-type-relations-query-description": "{{entities}} that have {{relationType}} relation {{direction}} {{rootEntity}}", "filter-type-asset-search-query": "Asset search query", "filter-type-asset-search-query-description": "Assets with types {{assetTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", "filter-type-device-search-query": "Device search query", "filter-type-device-search-query-description": "Devices with types {{deviceTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Entity view search query", + "filter-type-entity-view-search-query-description": "Entity views with types {{entityViewTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", "entity-filter": "Entity filter", "resolve-multiple": "Resolve as multiple entities", "filter-type": "Filter type", @@ -338,10 +348,12 @@ "dashboard": "Customer Dashboard", "dashboards": "Customer Dashboards", "devices": "Customer Devices", + "entity-views": "Customer Entity Views", "assets": "Customer Assets", "public-dashboards": "Public Dashboards", "public-devices": "Public Devices", "public-assets": "Public Assets", + "public-entity-views": "Public Entity Views", "add": "Add Customer", "delete": "Delete customer", "manage-customer-users": "Manage customer users", @@ -543,7 +555,12 @@ "alarm-fields-required": "Alarm fields are required.", "function-types": "Function types", "function-types-required": "Function types are required.", - "maximum-function-types": "Maximum { count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }" + "maximum-function-types": "Maximum { count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }", + "time-description": "timestamp of the current value;", + "value-description": "the current value;", + "prev-value-description": "result of the previous function call;", + "time-prev-description": "timestamp of the previous value;", + "prev-orig-value-description": "original previous value;" }, "datasource": { "type": "Datasource type", @@ -706,6 +723,10 @@ "type-assets": "Assets", "list-of-assets": "{ count, plural, 1 {One asset} other {List of # assets} }", "asset-name-starts-with": "Assets whose names start with '{{prefix}}'", + "type-entity-view": "Entity View", + "type-entity-views": "Entity Views", + "list-of-entity-views": "{ count, plural, 1 {One entity view} other {List of # entity views} }", + "entity-view-name-starts-with": "Entity Views whose names start with '{{prefix}}'", "type-rule": "Rule", "type-rules": "Rules", "list-of-rules": "{ count, plural, 1 {One rule} other {List of # rules} }", @@ -748,7 +769,99 @@ "entity-name": "Entity name", "details": "Entity details", "no-entities-prompt": "No entities found", - "no-data": "No data to display" + "no-data": "No data to display", + "columns-to-display": "Columns to Display" + }, + "entity-view": { + "entity-view": "Entity View", + "entity-view-required": "Entity view is required.", + "entity-views": "Entity Views", + "management": "Entity View management", + "view-entity-views": "View Entity Views", + "entity-view-alias": "Entity View alias", + "aliases": "Entity View aliases", + "no-alias-matching": "'{{alias}}' not found.", + "no-aliases-found": "No aliases found.", + "no-key-matching": "'{{key}}' not found.", + "no-keys-found": "No keys found.", + "create-new-alias": "Create a new one!", + "create-new-key": "Create a new one!", + "duplicate-alias-error": "Duplicate alias found '{{alias}}'.
Entity View aliases must be unique whithin the dashboard.", + "configure-alias": "Configure '{{alias}}' alias", + "no-entity-views-matching": "No entity views matching '{{entity}}' were found.", + "alias": "Alias", + "alias-required": "Entity View alias is required.", + "remove-alias": "Remove entity view alias", + "add-alias": "Add entity view alias", + "name-starts-with": "Entity View name starts with", + "entity-view-list": "Entity View list", + "use-entity-view-name-filter": "Use filter", + "entity-view-list-empty": "No entity views selected.", + "entity-view-name-filter-required": "Entity view name filter is required.", + "entity-view-name-filter-no-entity-view-matched": "No entity views starting with '{{entityView}}' were found.", + "add": "Add Entity View", + "assign-to-customer": "Assign to customer", + "assign-entity-view-to-customer": "Assign Entity View(s) To Customer", + "assign-entity-view-to-customer-text": "Please select the entity views to assign to the customer", + "no-entity-views-text": "No entity views found", + "assign-to-customer-text": "Please select the customer to assign the entity view(s)", + "entity-view-details": "Entity view details", + "add-entity-view-text": "Add new entity view", + "delete": "Delete entity view", + "assign-entity-views": "Assign entity views", + "assign-entity-views-text": "Assign { count, plural, 1 {1 entityView} other {# entityViews} } to customer", + "delete-entity-views": "Delete entity views", + "unassign-from-customer": "Unassign from customer", + "unassign-entity-views": "Unassign entity views", + "unassign-entity-views-action-title": "Unassign { count, plural, 1 {1 entityView} other {# entityViews} } from customer", + "assign-new-entity-view": "Assign new entity view", + "delete-entity-view-title": "Are you sure you want to delete the entity view '{{entityViewName}}'?", + "delete-entity-view-text": "Be careful, after the confirmation the entity view and all related data will become unrecoverable.", + "delete-entity-views-title": "Are you sure you want to entity view { count, plural, 1 {1 entityView} other {# entityViews} }?", + "delete-entity-views-action-title": "Delete { count, plural, 1 {1 entityView} other {# entityViews} }", + "delete-entity-views-text": "Be careful, after the confirmation all selected entity views will be removed and all related data will become unrecoverable.", + "unassign-entity-view-title": "Are you sure you want to unassign the entity view '{{entityViewName}}'?", + "unassign-entity-view-text": "After the confirmation the entity view will be unassigned and won't be accessible by the customer.", + "unassign-entity-view": "Unassign entity view", + "unassign-entity-views-title": "Are you sure you want to unassign { count, plural, 1 {1 entityView} other {# entityViews} }?", + "unassign-entity-views-text": "After the confirmation all selected entity views will be unassigned and won't be accessible by the customer.", + "entity-view-type": "Entity View type", + "entity-view-type-required": "Entity View type is required.", + "select-entity-view-type": "Select entity view type", + "enter-entity-view-type": "Enter entity view type", + "any-entity-view": "Any entity view", + "no-entity-view-types-matching": "No entity view types matching '{{entitySubtype}}' were found.", + "entity-view-type-list-empty": "No entity view types selected.", + "entity-view-types": "Entity View types", + "name": "Name", + "name-required": "Name is required.", + "description": "Description", + "events": "Events", + "details": "Details", + "copyId": "Copy entity view Id", + "assignedToCustomer": "Assigned to customer", + "unable-entity-view-device-alias-title": "Unable to delete entity view alias", + "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", + "select-entity-view": "Select entity view", + "make-public": "Make entity view public", + "start-date": "Start date", + "start-ts": "Start time", + "end-date": "End date", + "end-ts": "End time", + "date-limits": "Date limits", + "client-attributes": "Client attributes", + "shared-attributes": "Shared attributes", + "server-attributes": "Server attributes", + "timeseries": "Timeseries", + "client-attributes-placeholder": "Client attributes", + "shared-attributes-placeholder": "Shared attributes", + "server-attributes-placeholder": "Server attributes", + "timeseries-placeholder": "Timeseries", + "target-entity": "Target entity", + "attributes-propagation": "Attributes propagation", + "attributes-propagation-hint": "Entity View will automatically copy specified attributes from Target Entity each time you save or update this entity view. For performance reasons target entity attributes are not propagated to entity view on each attribute change. You can enable automatic propagation by configuring \"copy to view\" rule node in your rule chain and linking \"Post attributes\" and \"Attributes Updated\" messages to the new rule node.", + "timeseries-data": "Timeseries data", + "timeseries-data-hint": "Configure timeseries data keys of the target entity that will be accessible to the entity view. This timeseries data is read-only." }, "event": { "event-type": "Event type", @@ -1460,7 +1573,8 @@ "ko_KR": "Korean", "ru_RU": "Russian", "es_ES": "Spanish", - "ja_JA": "Japanese" + "ja_JA": "Japanese", + "tr_TR": "Turkish" } } } diff --git a/ui/src/app/locale/locale.constant-es_ES.json b/ui/src/app/locale/locale.constant-es_ES.json index 733db14ff4..7b0214190b 100644 --- a/ui/src/app/locale/locale.constant-es_ES.json +++ b/ui/src/app/locale/locale.constant-es_ES.json @@ -2,9 +2,9 @@ "access": { "unauthorized": "No autorizado", "unauthorized-access": "Acceso no autorizado", - "unauthorized-access-text": "Debes iniciar sesión para tener acceso a este recurso!", + "unauthorized-access-text": "Debe registrarse para tener acceso a este recurso!", "access-forbidden": "Acceso Prohibido", - "access-forbidden-text": "No tienes derechos para acceder a esta ubicación!
Intenta iniciar sesión con otro usuario si todavía quieres acceder a esta ubicación.", + "access-forbidden-text": "No tiene derechos para acceder a esta ubicación!
Intente registrarse con otro usuario si aún desea acceder a esta ubicación.", "refresh-token-expired": "La sesión ha expirado", "refresh-token-failed": "No se puede actualizar la sesión" }, @@ -15,342 +15,374 @@ "saveAs": "Guardar como", "cancel": "Cancelar", "ok": "OK", - "delete": "Borrar", + "delete": "Eliminar", "add": "Agregar", "yes": "Si", "no": "No", "update": "Actualizar", "remove": "Eliminar", "search": "Buscar", + "clear-search": "Borrar búsqueda", "assign": "Asignar", - "unassign": "Cancelar asignación", + "unassign": "Anular asignación", "share": "Compartir", "make-private": "Hacer privado", "apply": "Aplicar", "apply-changes": "Aplicar cambios", - "edit-mode": "Modo Edición", - "enter-edit-mode": "Modo Edición", + "edit-mode": "Modo edición", + "enter-edit-mode": "Ingresar modo edición", "decline-changes": "Descartar cambios", "close": "Cerrar", - "back": "Atrás", - "run": "Correr", - "sign-in": "Regístrate!", + "back": "Atras", + "run": "Ejecutar", + "sign-in": "Registrarse!", "edit": "Editar", "view": "Ver", "create": "Crear", "drag": "Arrastrar", - "refresh": "Refrescar", + "refresh": "Refrecar", "undo": "Deshacer", "copy": "Copiar", "paste": "Pegar", + "copy-reference": "Copiar referencia", + "paste-reference": "Pegar referencia", "import": "Importar", "export": "Exportar", - "share-via": "Compartir vía {{provider}}" + "share-via": "Share via {{provider}}" }, "aggregation": { "aggregation": "Agregación", - "function": "Función de Agregación", - "limit": "Valores Max", - "group-interval": "Intervalo de agrupación", + "function": "Función de agregación de datos", + "limit": "Valores máximos", + "group-interval": "Intervalo de agrupamiento", "min": "Min", "max": "Max", "avg": "Promedio", "sum": "Suma", - "count": "Cuenta", + "count": "Contar", "none": "Ninguno" }, "admin": { "general": "General", - "general-settings": "Ajustes General", - "outgoing-mail": "Mail de Salida", - "outgoing-mail-settings": "Ajustes del Mail de Salida", - "system-settings": "Sistema", - "test-mail-sent": "Mail de prueba enviado correctamente!", - "base-url": "URL Base", - "base-url-required": "URL Base requerida.", - "mail-from": "Mail Desde", - "mail-from-required": "Mail Desde requerido.", + "general-settings": "Configuración general", + "outgoing-mail": "Servidor de correo", + "outgoing-mail-settings": "Configuración del servidor de correo de salida", + "system-settings": "Configuración del sistema", + "test-mail-sent": "Correo de prueba fue enviado exitosamente!", + "base-url": "URL base", + "base-url-required": "URL base es requerida.", + "mail-from": "Correo desde", + "mail-from-required": "Correo Desde es requerido.", "smtp-protocol": "Protocolo SMTP", "smtp-host": "Host SMTP", - "smtp-host-required": "Host SMTP requerido.", + "smtp-host-required": "Host SMTP es requerido.", "smtp-port": "Puerto SMTP", - "smtp-port-required": "Debe ingresar un Puerto SMTP.", - "smtp-port-invalid": "No parece un Puerto SMTP valido.", - "timeout-msec": "Timeout (ms)", - "timeout-required": "Timeout requerido.", - "timeout-invalid": "No parece un Timeout valido.", + "smtp-port-required": "Debe suministrar un puerto SMTP", + "smtp-port-invalid": "Eso no parece un puerto SMTP válido.", + "timeout-msec": "Tiempo de espera (ms)", + "timeout-required": "Tiempo de espera es requerido.", + "timeout-invalid": "Eso no parece un tiempo de espera válido.", "enable-tls": "Habilitar TLS", - "send-test-mail": "Enviar mail de prueba" + "send-test-mail": "Enviar correo de prueba" }, "alarm": { - "alarm": "Alarm", - "alarms": "Alarms", - "select-alarm": "Select alarm", - "no-alarms-matching": "No alarms matching '{{entity}}' were found.", - "alarm-required": "Alarm is required", - "alarm-status": "Alarm status", + "alarm": "Alarma", + "alarms": "Alarmas", + "select-alarm": "Seleccionar alarma", + "no-alarms-matching": "Alarmas que coincidan con '{{entity}}' no fueron encontradas.", + "alarm-required": "Alarma es requerida", + "alarm-status": "Estado de la alarma", "search-status": { - "ANY": "Any", - "ACTIVE": "Active", - "CLEARED": "Cleared", - "ACK": "Acknowledged", - "UNACK": "Unacknowledged" + "ANY": "Alguna", + "ACTIVE": "Activa", + "CLEARED": "Borrada", + "ACK": "Reconocida", + "UNACK": "Ignorada" }, "display-status": { - "ACTIVE_UNACK": "Active Unacknowledged", - "ACTIVE_ACK": "Active Acknowledged", - "CLEARED_UNACK": "Cleared Unacknowledged", - "CLEARED_ACK": "Cleared Acknowledged" + "ACTIVE_UNACK": "Activa ignorada", + "ACTIVE_ACK": "Activa reconocida", + "CLEARED_UNACK": "Borrada ignorada", + "CLEARED_ACK": "Borrada reconocida" }, - "no-alarms-prompt": "No alarms found", - "created-time": "Created time", - "type": "Type", - "severity": "Severity", - "originator": "Originator", - "originator-type": "Originator type", - "details": "Details", - "status": "Status", - "alarm-details": "Alarm details", - "start-time": "Start time", - "end-time": "End time", - "ack-time": "Acknowledged time", - "clear-time": "Cleared time", - "severity-critical": "Critical", - "severity-major": "Major", - "severity-minor": "Minor", - "severity-warning": "Warning", - "severity-indeterminate": "Indeterminate", - "acknowledge": "Acknowledge", - "clear": "Clear", - "search": "Search alarms", - "selected-alarms": "{ count, plural, 1 {1 alarm} other {# alarms} } selected", - "no-data": "No data to display", - "polling-interval": "Alarms polling interval (sec)", - "polling-interval-required": "Alarms polling interval is required.", - "min-polling-interval-message": "At least 1 sec polling interval is allowed.", - "aknowledge-alarms-title": "Acknowledge { count, plural, 1 {1 alarm} other {# alarms} }", - "aknowledge-alarms-text": "Are you sure you want to acknowledge { count, plural, 1 {1 alarm} other {# alarms} }?", - "clear-alarms-title": "Clear { count, plural, 1 {1 alarm} other {# alarms} }", - "clear-alarms-text": "Are you sure you want to clear { count, plural, 1 {1 alarm} other {# alarms} }?" + "no-alarms-prompt": "Alarmas no encontradas", + "created-time": "Tiempo de creación", + "type": "Tipo", + "severity": "Severidad", + "originator": "Origen", + "originator-type": "Tipo de origen", + "details": "Detalles", + "status": "Estado", + "alarm-details": "Detalles de la alarma", + "start-time": "Tiempo de inicio", + "end-time": "Tiempo de finalización", + "ack-time": "Tiempo de reconocimiento", + "clear-time": "Tiempo de borrado", + "severity-critical": "Crítica", + "severity-major": "Mayor", + "severity-minor": "Menor", + "severity-warning": "Alerta", + "severity-indeterminate": "Indeterminada", + "acknowledge": "Reconocer", + "clear": "Borrar", + "search": "buscar alarmas", + "selected-alarms": "{ count, plural, 1 {1 alarm} other {# alarms} } seleccionadas", + "no-data": "No hay datos para mostrar", + "polling-interval": "Intervalo de sondeo de alarmas (seg)", + "polling-interval-required": "Intervalo de sondeo de alarmas es requerido.", + "min-polling-interval-message": "Se permite al menos 1 segundo de intervalo de sondeo.", + "aknowledge-alarms-title": "Reconocer { count, plural, 1 {1 alarm} other {# alarms} }", + "aknowledge-alarms-text": "¿Está seguro de que desea reconocer { count, plural, 1 {1 alarm} other {# alarms} }?", + "aknowledge-alarm-title": "Acknowledge Alarm", + "aknowledge-alarm-text": "Are you sure you want to acknowledge Alarm?", + "clear-alarms-title": "Borrar { count, plural, 1 {1 alarm} other {# alarms} }", + "clear-alarms-text": "¿Está seguro de que desea borrar { count, plural, 1 {1 alarm} other {# alarms} }?", + "clear-alarm-title": "Clear Alarm", + "clear-alarm-text": "Are you sure you want to clear Alarm?", + "alarm-status-filter": "Alarm Status Filter" }, "alias": { - "add": "Add alias", - "edit": "Edit alias", - "name": "Alias name", - "name-required": "Alias name is required", - "duplicate-alias": "Alias with same name is already exists.", - "filter-type-single-entity": "Single entity", - "filter-type-entity-list": "Entity list", - "filter-type-entity-name": "Entity name", - "filter-type-state-entity": "Entity from dashboard state", - "filter-type-state-entity-description": "Entity taken from dashboard state parameters", - "filter-type-asset-type": "Asset type", - "filter-type-asset-type-description": "Assets of type '{{assetType}}'", - "filter-type-asset-type-and-name-description": "Assets of type '{{assetType}}' and with name starting with '{{prefix}}'", - "filter-type-device-type": "Device type", - "filter-type-device-type-description": "Devices of type '{{deviceType}}'", - "filter-type-device-type-and-name-description": "Devices of type '{{deviceType}}' and with name starting with '{{prefix}}'", - "filter-type-relations-query": "Relations query", - "filter-type-relations-query-description": "{{entities}} that have {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-asset-search-query": "Asset search query", - "filter-type-asset-search-query-description": "Assets with types {{assetTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-device-search-query": "Device search query", - "filter-type-device-search-query-description": "Devices with types {{deviceTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", - "entity-filter": "Entity filter", - "resolve-multiple": "Resolve as multiple entities", - "filter-type": "Filter type", - "filter-type-required": "Filter type is required.", - "entity-filter-no-entity-matched": "No entities matching specified filter were found.", - "no-entity-filter-specified": "No entity filter specified", - "root-state-entity": "Use dashboard state entity as root", - "root-entity": "Root entity", - "state-entity-parameter-name": "State entity parameter name", - "default-state-entity": "Default state entity", - "default-entity-parameter-name": "By default", - "max-relation-level": "Max relation level", - "unlimited-level": "Unlimited level", - "state-entity": "Dashboard state entity", - "all-entities": "All entities", - "any-relation": "any" + "add": "Agregar alias", + "edit": "Editar alias", + "name": "Nombre de alias", + "name-required": "Nombre de alias es requerido", + "duplicate-alias": "Ya existe un alias con el mismo nombre.", + "filter-type-single-entity": "Entidad única", + "filter-type-entity-list": "Lista de entidades", + "filter-type-entity-name": "Nombre de entidad", + "filter-type-state-entity": "Entidad del panel de estados", + "filter-type-state-entity-description": "Entidad tomada desde los parámetro del panel de estados", + "filter-type-asset-type": "Tipo de activo", + "filter-type-asset-type-description": "Activos de tipo '{{assetType}}'", + "filter-type-asset-type-and-name-description": "Activos de tipo '{{assetType}}' y con nombre comenzando con '{{prefix}}'", + "filter-type-device-type": "Tipo de dispositivo", + "filter-type-device-type-description": "Dispositivos de tipo '{{deviceType}}'", + "filter-type-device-type-and-name-description": "Dispositivos de tipo '{{deviceType}}' y con nombre comenzando con '{{prefix}}'", + "filter-type-relations-query": "Consulta de relaciones", + "filter-type-relations-query-description": "{{entities}} que tienen {{relationType}} relación {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Consultar búsqueda de activos", + "filter-type-asset-search-query-description": "Activos con tipos {{assetTypes}} que tienen {{relationType}} relación {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Consultar búqueda de dispositivos", + "filter-type-device-search-query-description": "Dispositivos con tipos {{deviceTypes}} que tienen {{relationType}} relación {{direction}} {{rootEntity}}", + "entity-filter": "Filtro de entidad", + "resolve-multiple": "Resolver como entidades múltiples", + "filter-type": "Tipo de filtro", + "filter-type-required": "Tipo de filtro es requerido.", + "entity-filter-no-entity-matched": "Entidades que coincidan con el filtro especificado no fueron encontradas.", + "no-entity-filter-specified": "No se especificó el filtro de entidad", + "root-state-entity": "Utilizar la entidad del panel de estados como raíz", + "root-entity": "Entidad raíz", + "state-entity-parameter-name": "Nombre de parámetro de entidad de estado", + "default-state-entity": "Entidad de estado predeterminada", + "default-entity-parameter-name": "Por defecto", + "max-relation-level": "Nivel máximo de relación", + "unlimited-level": "Nivel ilimitado", + "state-entity": "Entidad del panel de estados", + "all-entities": "Todas las entidades", + "any-relation": "alguna" }, "asset": { - "asset": "Asset", - "assets": "Assets", - "management": "Asset management", - "view-assets": "View Assets", - "add": "Add Asset", - "assign-to-customer": "Assign to customer", - "assign-asset-to-customer": "Assign Asset(s) To Customer", - "assign-asset-to-customer-text": "Please select the assets to assign to the customer", - "no-assets-text": "No assets found", - "assign-to-customer-text": "Please select the customer to assign the asset(s)", - "public": "Public", - "assignedToCustomer": "Assigned to customer", - "make-public": "Make asset public", - "make-private": "Make asset private", - "unassign-from-customer": "Unassign from customer", - "delete": "Delete asset", - "asset-public": "Asset is public", - "asset-type": "Asset type", - "asset-type-required": "Asset type is required.", - "select-asset-type": "Select asset type", - "enter-asset-type": "Enter asset type", - "any-asset": "Any asset", - "no-asset-types-matching": "No asset types matching '{{entitySubtype}}' were found.", - "asset-type-list-empty": "No asset types selected.", - "asset-types": "Asset types", - "name": "Name", - "name-required": "Name is required.", - "description": "Description", - "type": "Type", - "type-required": "Type is required.", - "details": "Details", - "events": "Events", - "add-asset-text": "Add new asset", - "asset-details": "Asset details", - "assign-assets": "Assign assets", - "assign-assets-text": "Assign { count, plural, 1 {1 asset} other {# assets} } to customer", - "delete-assets": "Delete assets", - "unassign-assets": "Unassign assets", - "unassign-assets-action-title": "Unassign { count, plural, 1 {1 asset} other {# assets} } from customer", - "assign-new-asset": "Assign new asset", - "delete-asset-title": "Are you sure you want to delete the asset '{{assetName}}'?", - "delete-asset-text": "Be careful, after the confirmation the asset and all related data will become unrecoverable.", - "delete-assets-title": "Are you sure you want to delete { count, plural, 1 {1 asset} other {# assets} }?", - "delete-assets-action-title": "Delete { count, plural, 1 {1 asset} other {# assets} }", - "delete-assets-text": "Be careful, after the confirmation all selected assets will be removed and all related data will become unrecoverable.", - "make-public-asset-title": "Are you sure you want to make the asset '{{assetName}}' public?", - "make-public-asset-text": "After the confirmation the asset and all its data will be made public and accessible by others.", - "make-private-asset-title": "Are you sure you want to make the asset '{{assetName}}' private?", - "make-private-asset-text": "After the confirmation the asset and all its data will be made private and won't be accessible by others.", - "unassign-asset-title": "Are you sure you want to unassign the asset '{{assetName}}'?", - "unassign-asset-text": "After the confirmation the asset will be unassigned and won't be accessible by the customer.", - "unassign-asset": "Unassign asset", - "unassign-assets-title": "Are you sure you want to unassign { count, plural, 1 {1 asset} other {# assets} }?", - "unassign-assets-text": "After the confirmation all selected assets will be unassigned and won't be accessible by the customer.", - "copyId": "Copy asset Id", - "idCopiedMessage": "Asset Id has been copied to clipboard", - "select-asset": "Select asset", - "no-assets-matching": "No assets matching '{{entity}}' were found.", - "asset-required": "Asset is required", - "name-starts-with": "Asset name starts with" + "asset": "Activo", + "assets": "Activos", + "management": "Gestión de activos", + "view-assets": "Ver Activos", + "add": "Agregar Activo", + "assign-to-customer": "Asignar al cliente", + "assign-asset-to-customer": "Asignar Activo(s) Al Cliente", + "assign-asset-to-customer-text": "Por favor seleccionar los activos para asignar al cliente", + "no-assets-text": "Activos no encontrados", + "assign-to-customer-text": "Por favor seleccionar el cliente para asignar el(los) activo(s)", + "public": "Público", + "assignedToCustomer": "Asignado al cliente", + "make-public": "Hacer público el activo", + "make-private": "Hacer privado el activo", + "unassign-from-customer": "Anular asignación del cliente", + "delete": "Eliminar activo", + "asset-public": "El activo es público", + "asset-type": "Tipo de activo", + "asset-type-required": "El tipo de activo es requerido.", + "select-asset-type": "Seleccionar tipo de activo", + "enter-asset-type": "Ingresar tipo de activo", + "any-asset": "Algún activo", + "no-asset-types-matching": "Tipos de activos que coincidan con '{{entitySubtype}}' no fueron encontrados.", + "asset-type-list-empty": "No se seleccionaron tipos de activos.", + "asset-types": "Tipos de activos", + "name": "Nombre", + "name-required": "El nombre es requerido.", + "description": "Descripción", + "type": "Tipo", + "type-required": "El tipo es requerido.", + "details": "Detalles", + "events": "Eventos", + "add-asset-text": "Agregar nuevos activos", + "asset-details": "Detalles del activo", + "assign-assets": "Asignar activos", + "assign-assets-text": "Asignar { count, plural, 1 {1 asset} other {# assets} } al cliente", + "delete-assets": "Eliminar activos", + "unassign-assets": "Anular asignación de activos", + "unassign-assets-action-title": "Anular asignación { count, plural, 1 {1 asset} other {# assets} } del cliente", + "assign-new-asset": "Asignar nuevo activo", + "delete-asset-title": "¿Está seguro de que desea eliminar el activo '{{assetName}}'?", + "delete-asset-text": "Tener cuidado, después de la confirmación, el activo y todos los datos relacionados se volverán irrecuperables.", + "delete-assets-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 asset} other {# assets} }?", + "delete-assets-action-title": "Eliminar { count, plural, 1 {1 asset} other {# assets} }", + "delete-assets-text": "Tener cuidado, después de la confirmación se eliminarán todos los activos seleccionados y todos los datos relacionados se volverán irrecuperables.", + "make-public-asset-title": "¿Está seguro de que desea que el activo '{{assetName}}' sea público?", + "make-public-asset-text": "Después de la confirmación, el activo y todos sus datos se harán públicos y accesibles por otros.", + "make-private-asset-title": "¿Está seguro de que desea que el activo '{{assetName}}' sea privado?", + "make-private-asset-text": "Después de la confirmación, el activo y todos sus datos se harán privados y no serán accesibles para otros", + "unassign-asset-title": "¿Está seguro de que desea anular asignación del activo '{{assetName}}'?", + "unassign-asset-text": "Después de la confirmación, se anulará asignación del activo y no será accesible por el cliente.", + "unassign-asset": "Anular asignación activo", + "unassign-assets-title": "¿Está seguro de que desea anular asignación { count, plural, 1 {1 asset} other {# assets} }?", + "unassign-assets-text": "Después de la confirmación, se anulará asignación de todos los activos seleccionados y no serán accesibles por el cliente", + "copyId": "Copiar ID del activo", + "idCopiedMessage": "ID del activo has sido copiada al portapapeles", + "select-asset": "Seleccionar activo", + "no-assets-matching": "Activos que coincidan con '{{entity}}' no fueron encontrados.", + "asset-required": "El activo es requerido", + "name-starts-with": "El nombre del activo comienza con" }, "attribute": { "attributes": "Atributos", "latest-telemetry": "Última telemetría", - "attributes-scope": "Alcance de los atributos del dispositivo", + "attributes-scope": "Alcance de los atributos de la entidad", "scope-latest-telemetry": "Última telemetría", - "scope-client": "Atributos del Cliente", - "scope-server": "Atributos del Servidor", - "scope-shared": "Atributos Compartidos", - "add": "Agregar atributo", + "scope-client": "Atributos del cliente", + "scope-server": "Atributos del servidor", + "scope-shared": "Atributos compartidos", + "add": "Agregar atributos", "key": "Clave", - "key-required": "Clave del atributo requerida.", + "last-update-time": "Hora de la última actualización", + "key-required": "La clave del aributo es requerida.", "value": "Valor", - "value-required": "Valor del atributo requerido.", - "delete-attributes-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 atributo} other {# atributos} }?", - "delete-attributes-text": "Ten cuidado, luego de confirmar el atributo será eliminado, y la información relacionada será irrecuperable.", - "delete-attributes": "Borrar atributo", + "value-required": "Valor del atributo es requerido.", + "delete-attributes-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 attribute} other {# attributes} }?", + "delete-attributes-text": "Tener cuidado, después de la confirmación, se eliminarán todos los atributos seleccionados.", + "delete-attributes": "Eliminar atributos", "enter-attribute-value": "Ingresar valor del atributo", - "show-on-widget": "Mostrar en Widget", - "widget-mode": "Widget", + "show-on-widget": "Mostrar en widget", + "widget-mode": "Modo widget", "next-widget": "Widget siguiente", - "prev-widget": "Widget anterior", - "add-to-dashboard": "Agregar al Panel", - "add-widget-to-dashboard": "Agregar widget al Panel", - "selected-attributes": "{ count, plural, 1 {1 atributo} other {# atributos} } seleccionados", - "selected-telemetry": "{ count, plural, 1 {1 unidad de telemetría } other {# unidades de telemetría} } seleccionadas." + "prev-widget": "Widget previo", + "add-to-dashboard": "Agregar al panel", + "add-widget-to-dashboard": "Agregar widget al panel", + "selected-attributes": "{ count, plural, 1 {1 attribute} other {# attributes} } seleccionados", + "selected-telemetry": "{ count, plural, 1 {1 telemetry unit} other {# telemetry units} } seleccionadas" }, "audit-log": { - "audit": "Audit", - "audit-logs": "Audit Logs", - "timestamp": "Timestamp", - "entity-type": "Entity Type", - "entity-name": "Entity Name", - "user": "User", - "type": "Type", - "status": "Status", - "details": "Details", - "type-added": "Added", - "type-deleted": "Deleted", - "type-updated": "Updated", - "type-attributes-updated": "Attributes updated", - "type-attributes-deleted": "Attributes deleted", - "type-rpc-call": "RPC call", - "type-credentials-updated": "Credentials updated", - "type-assigned-to-customer": "Assigned to Customer", - "type-unassigned-from-customer": "Unassigned from Customer", - "type-activated": "Activated", - "type-suspended": "Suspended", - "type-credentials-read": "Credentials read", - "type-attributes-read": "Attributes read", - "status-success": "Success", - "status-failure": "Failure", - "audit-log-details": "Audit log details", - "no-audit-logs-prompt": "No logs found", - "action-data": "Action data", - "failure-details": "Failure details", - "search": "Search audit logs", - "clear-search": "Clear search" + "audit": "Auditar", + "audit-logs": "Auditar Registros", + "timestamp": "Marca de tiempo", + "entity-type": "Tipo de Entidad", + "entity-name": "Nombre de Entidad", + "user": "Usuario", + "type": "Tipo", + "status": "Estado", + "details": "Detalles", + "type-added": "Agregado", + "type-deleted": "Eliminado", + "type-updated": "Actualizado", + "type-attributes-updated": "Atributos actualizados", + "type-attributes-deleted": "Atributos eliminados", + "type-rpc-call": "Llamada RPC", + "type-credentials-updated": "Credenciales actualizadas", + "type-assigned-to-customer": "Asignado al Cliente", + "type-unassigned-from-customer": "Asignación anulada del cliente", + "type-activated": "Activado", + "type-suspended": "Suspendido", + "type-credentials-read": "Credenciales leídas", + "type-attributes-read": "Atributos leídos", + "type-relation-add-or-update": "Relación actualizada", + "type-relation-delete": "Relación eliminada", + "type-relations-delete": "Toda relación eliminada", + "type-alarm-ack": "Reconocida", + "type-alarm-clear": "Borrada", + "status-success": "Éxito", + "status-failure": "Falla", + "audit-log-details": "Auditar detalles de regisstro", + "no-audit-logs-prompt": "Registros no encontrados", + "action-data": "Datos de acción", + "failure-details": "Detalles de falla", + "search": "Buscar registros de auditoría", + "clear-search": "Borrar búsqueda" }, "confirm-on-exit": { - "message": "Tienes cambios sin guardar. ¿Estás seguro que quieres abandonar la página?", - "html-message": "Tienes cambios sin guardar.
¿Estás seguro que quieres abandonar la página?", + "message": "Tiene cambios sin guardar. ¿Está seguro de que desea salir de esta página?", + "html-message": "Tiene cambios sin guardar..
¿Está seguro de que desea salir de esta página?", "title": "Cambios sin guardar" }, "contact": { "country": "País", "city": "Ciudad", - "state": "Estado/Provincia", + "state": "Estado / Provincia", "postal-code": "Código Postal", - "postal-code-invalid": "Solo se permiten dígitos.", + "postal-code-invalid": "Formato de código postal inválido.", "address": "Dirección", "address2": "Dirección 2", "phone": "Teléfono", - "email": "Email", - "no-address": "Sin Dirección" + "email": "Correo Electrónico", + "no-address": "Sin dirección" }, "common": { - "username": "Usuario", + "username": "Nombre de usuario", "password": "Contraseña", - "enter-username": "Ingresa el nombre de usuario.", - "enter-password": "Ingresa la contraseña", - "enter-search": "Ingresa búsqueda" + "enter-username": "Ingresar nombre de usuario", + "enter-password": "Ingresar contraseña", + "enter-search": "Ingresar búsqueda" }, "content-type": { "json": "Json", - "text": "Text", - "binary": "Binary (Base64)" + "text": "Texto", + "binary": "Binario (Base64)" }, "customer": { + "customer": "Cliente", "customers": "Clientes", - "management": "Gestión de Clientes", + "management": "Gestión del cliente", "dashboard": "Panel del Cliente", "dashboards": "Paneles del Cliente", - "devices": "Panel del Cliente", + "devices": "Dispositivos del Cliente", + "entity-views": "Customer Entity Views", + "assets": "Activos del Cliente", "public-dashboards": "Paneles Públicos", "public-devices": "Dispositivos Públicos", + "public-assets": "Activos Públicos", + "public-entity-views": "Public Entity Views", "add": "Agregar cliente", - "delete": "Borrar cliente", + "delete": "Eliminar cliente", "manage-customer-users": "Gestionar usuarios del cliente", "manage-customer-devices": "Gestionar dispositivos del cliente", "manage-customer-dashboards": "Gestionar paneles del cliente", "manage-public-devices": "Gestionar dispositivos públicos", "manage-public-dashboards": "Gestionar paneles públicos", + "manage-customer-assets": "Gestionar activos del cliente", + "manage-public-assets": "Gestionar activos públicos", "add-customer-text": "Agregar nuevo cliente", - "no-customers-text": "No se encontrar clientes", + "no-customers-text": "Clientes no encontrados", "customer-details": "Detalles del cliente", - "delete-customer-title": "¿Estás seguro que quieres eliminar el cliente '{{customerTitle}}'?", - "delete-customer-text": "Ten cuidado, luego de confirmar el cliente será eliminado y toda la información relacionada será irrecuperable.", - "delete-customers-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 cliente} other {# clientes} }?", - "delete-customers-action-title": "Borrar { count, plural, 1 {1 cliente} other {# clientes} }", - "delete-customers-text": "Ten cuidado, luego de confirmar todos los clientes seleccionados serán eliminados y su información relacionada será irrecuperable.", + "delete-customer-title": "¿Está seguro de que desea eliminar al cliente '{{customerTitle}}'?", + "delete-customer-text": "Tener cuidado, después de la confirmación, el cliente y todos los datos relacionados se volverán irrecuperables.", + "delete-customers-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 customer} other {# customers} }?", + "delete-customers-action-title": "Eliminar { count, plural, 1 {1 customer} other {# customers} }", + "delete-customers-text": "Tener cuidado, después de la confirmación, todos los clientes seleccionados serán eliminados y todos los datos relacionados se volverán irrecuperables.", "manage-users": "Gestionar usuarios", + "manage-assets": "Gestionar activos", "manage-devices": "Gestionar dispositivos", "manage-dashboards": "Gestionar paneles", "title": "Título", - "title-required": "Título requerido.", - "description": "Descripción" + "title-required": "El título es requerido.", + "description": "Descripción", + "details": "Detalles", + "events": "Eventos", + "copyId": "Copiar ID del cliente", + "idCopiedMessage": "ID del cliente ha sido copiada al portapapeles", + "select-customer": "Seleccionar cliente", + "no-customers-matching": "Clientes que coincidan con '{{entity}}' no fueron encontrados.", + "customer-required": "El cliente es requerido", + "select-default-customer": "Seleccionar cliente predeterminado", + "default-customer": "Cliente predeterminado", + "default-customer-required": "Cliente predeterminado es requerido para depurar el panel en el nivel Organización" }, "datetime": { "date-from": "Fecha desde", @@ -361,582 +393,733 @@ "dashboard": { "dashboard": "Panel", "dashboards": "Paneles", - "management": "Gestión de Paneles", - "view-dashboards": "Ver paneles", - "add": "Agregar Panel", - "assign-dashboard-to-customer": "Asignar panel(es) a cliente", - "assign-dashboard-to-customer-text": "Por favor, seleccione algún panel para asignar al Cliente.", - "assign-to-customer-text": "Por favor, seleccione algún cliente para asignar al(los) panel(es).", - "assign-to-customer": "Asignar a cliente", - "unassign-from-customer": "Desasignar del cliente", + "management": "Gestión del panel", + "view-dashboards": "Ver Panel", + "add": "Agregar Paneles", + "assign-dashboard-to-customer": "Asignar Panel(es) Al Cliente", + "assign-dashboard-to-customer-text": "Por favor seleccionar los paneles para asignar al cliente", + "assign-to-customer-text": "Por favor seleccionar el cliente para asignar el(los) panel(es)", + "assign-to-customer": "Asignar al cliente", + "unassign-from-customer": "Anular asignación del cliente", "make-public": "Hacer panel público", - "make-private": "Hacer panel privado", - "no-dashboards-text": "Ningún panel encontrado", - "no-widgets": "Ningún widget configurado", + "make-private": "Hcer panel privado", + "manage-assigned-customers": "Gestionar clientes asignados", + "assigned-customers": "Clientes asignados", + "assign-to-customers": "Asignar Panel(es) Al(Los) Cliente(s)", + "assign-to-customers-text": "Por favor seleccionar los clientes para asignar el(los) panel(es)", + "unassign-from-customers": "Anular Asignación Del(De Los) Panel(es) De Los Clientes", + "unassign-from-customers-text": "Por favor seeccionar los clientes oara anular asignación del(de los) panel(es)", + "no-dashboards-text": "Paneles no encontrados", + "no-widgets": "Sin widgets configurados", "add-widget": "Agregar nuevo widget", - "title": "Titulo", + "title": "Título", "select-widget-title": "Seleccionar widget", - "select-widget-subtitle": "Lista de tipos de widgets", + "select-widget-subtitle": "Lista de tipos de widget disponibles", "delete": "Eliminar panel", - "title-required": "Título requerido.", + "title-required": "El título es requerido.", "description": "Descripción", "details": "Detalles", - "dashboard-details": "Detalles del panel", + "dashboard-details": "Detalles del Panel", "add-dashboard-text": "Agregar nuevo panel", "assign-dashboards": "Asignar paneles", - "assign-new-dashboard": "Asignar nuevo panel", - "assign-dashboards-text": "Asignar { count, plural, 1 {1 panel} other {# paneles} } al cliente", + "assign-new-dashboard": "Aignar nuevo panel", + "assign-dashboards-text": "Asignar { count, plural, 1 {1 dashboard} other {# dashboards} } a los clientes", + "unassign-dashboards-action-text": "Anular asignación { count, plural, 1 {1 dashboard} other {# dashboards} } de los clientes", "delete-dashboards": "Eliminar paneles", - "unassign-dashboards": "Desasignar paneles", - "unassign-dashboards-action-title": "Desasignar { count, plural, 1 {1 paneles} other {# paneles} } del cliente", - "delete-dashboard-title": "¿Estás seguro que quieres eliminar el panel '{{dashboardTitle}}'?", - "delete-dashboard-text": "Ten cuidado, el panel seleccionado será eliminado y la información relacionada sera irrecuperable.", - "delete-dashboards-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 panel} other {# paneles} }?", - "delete-dashboards-action-title": "Eliminar { count, plural, 1 {1 panel} other {# paneles} }", - "delete-dashboards-text": "Ten cuidado, los paneles seleccionados serán eliminados y la información relacionada será irrecuperable.", - "unassign-dashboard-title": "¿Estás seguro que quieres desasignar el panel '{{dashboardTitle}}'?", - "unassign-dashboard-text": "Luego de confirmar, el panel será desasignado y no podrá ser accesible por el cliente.", - "unassign-dashboard": "Desasignar panel", - "unassign-dashboards-title": "¿Estás seguro que quieres desasignar { count, plural, 1 {1 panel} other {# paneles} }?", - "unassign-dashboards-text": "Luego de confirmar, los paneles seleccionados serán desasignados y no podrán ser accesibles por el cliente.", - "public-dashboard-title": "El panel ahora es público", - "public-dashboard-text": "Tu panel {{dashboardTitle}} es ahora público y podrá ser accedido desde: aquí:", - "public-dashboard-notice": "Nota: No olvides hacer públicos los dispositivos relacionados para acceder a sus datos.", - "make-private-dashboard-title": "¿Estás seguro que quieres hacer el panel '{{dashboardTitle}}' privado?", - "make-private-dashboard-text": "Luego de confirmar, el panel será privado y no podrá ser accesible por otros.", - "make-private-dashboard": "Hacer panel privado", - "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", - "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "unassign-dashboards": "Anular asignación de paneles", + "unassign-dashboards-action-title": "Anular asignación { count, plural, 1 {1 dashboard} other {# dashboards} } del cliente", + "delete-dashboard-title": "¿Está seguro de que desea eliminar el panel '{{dashboardTitle}}'?", + "delete-dashboard-text": "Tener cuidado, después de la confirmación, el panel y todos los datos relacionados se volverán irrecuperables.", + "delete-dashboards-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "delete-dashboards-action-title": "Eliminar { count, plural, 1 {1 dashboard} other {# dashboards} }", + "delete-dashboards-text": "Tener cuidado, después de la confirmación, todos los paneles seleccionados serán eliminados y todos los datos relacionados se volverán irrecuperables.", + "unassign-dashboard-title": "¿Está seguro de que desea anular la asignación del panel '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Después de la confirmación, se anulará la asignación del panel y no será accesible por el cliente.", + "unassign-dashboard": "Anular asignación del panel", + "unassign-dashboards-title": "¿Está seguro de que desea anular asignación { count, plural, 1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-text": "Después de la confirmación, se anulará la asignación de todos los paneles seleccionados y no serán accesibles por el cliente.", + "public-dashboard-title": "El panel es ahora público", + "public-dashboard-text": "Su panel {{dashboardTitle}} es ahora público y es accesible a través del siguiente enlace público :", + "public-dashboard-notice": "Nota No olvide hacer públicos los dispositivos relacionados para acceder a sus datos.", + "make-private-dashboard-title": "¿Está seguro de que desea hacer el panel '{{dashboardTitle}}' privado?", + "make-private-dashboard-text": "Después de la confirmación el panel se hará privado y no será accesible por otros.", + "make-private-dashboard": "Hacer el panel privado", + "socialshare-text": "'{{dashboardTitle}}' desarrollado por ThingsBoard.", + "socialshare-title": "'{{dashboardTitle}}' desarrollado por ThingsBoard", "select-dashboard": "Seleccionar panel", - "no-dashboards-matching": "Panel '{{entity}}' no encontrado.", - "dashboard-required": "Panel requerido.", - "select-existing": "Seleccionar paneles existentes", + "no-dashboards-matching": "Paneles que coincidan con '{{entity}}' no fueron encontrados.", + "dashboard-required": "Panel es requerido.", + "select-existing": "Seleccionar panel existente", "create-new": "Crear nuevo panel", - "new-dashboard-title": "Nuevo título", + "new-dashboard-title": "Nuevo título de panel", "open-dashboard": "Abrir panel", "set-background": "Definir fondo", "background-color": "Color de fondo", "background-image": "Imagen de fondo", - "background-size-mode": "Modo tamaño de fondo", - "no-image": "No se ha seleccionado ningúna imagen", - "drop-image": "Suelte una imagen o haga clic para seleccionar un archivo para cargar.", - "settings": "Ajustes", - "columns-count": "Número de columnas", - "columns-count-required": "Número de columnas requerido.", - "min-columns-count-message": "Solo se permite un número mínimo de 10 columnas.", - "max-columns-count-message": "Solo se permite un número máximo de 1000 columnas.", + "background-size-mode": "Modo de tamaño de fondo", + "no-image": "Ninguna imagen seleccionada", + "drop-image": "Colocar una imagen o hacer clic para seleccionar un archivo para cargar.", + "settings": "Configuración", + "columns-count": "Conteo de columnas", + "columns-count-required": "Conteo de columnas es requerido.", + "min-columns-count-message": "Solamente contar 10 columnas como mínimo es permitido", + "max-columns-count-message": "Solamente contar 1000 columnas como máximo es permitiddo", "widgets-margins": "Margen entre widgets", "horizontal-margin": "Margen horizontal", - "horizontal-margin-required": "Margen horizontal requerido.", - "min-horizontal-margin-message": "Solo se permite margen horizontal mínimo de 0.", - "max-horizontal-margin-message": "Solo se permite margen horizontal máximo de 50.", + "horizontal-margin-required": "El valor del margen horizontal es requerido.", + "min-horizontal-margin-message": "Solamente es permitido el 0 como valor mínimo para el margen horizontal", + "max-horizontal-margin-message": "Solamente es permitido el 50 como valor máximo para el margen horizontal", "vertical-margin": "Margen vertical", - "vertical-margin-required": "Margen vertical requerido.", - "min-vertical-margin-message": "Solo se permite margen vertical mínimo de 0.", - "max-vertical-margin-message": "Solo se permite margen vertical máximo de 50.", + "vertical-margin-required": "El valor del margen vertical es requerido.", + "min-vertical-margin-message": "Solamente es permitido el 0 como valor mínimo para el margen vertical.", + "max-vertical-margin-message": "Solamente es permitido el 50 como valor máximo para el margen vertical", + "autofill-height": "Llenado automático de altura de diseño", + "mobile-layout": "Configuración de diseño para móvil", + "mobile-row-height": "Altura de fila para móvil, píxel", + "mobile-row-height-required": "Altura de fila para móvil es requerida.", + "min-mobile-row-height-message": "Solamente es permitido 5 píxeles como valor mínimo de altura de fila para móvil.", + "max-mobile-row-height-message": "Solamente es permitido 200 píxeles como valor máximo de altura de fila para móvil.", "display-title": "Mostrar título del panel", + "toolbar-always-open": "Mantener la barra de herramientas abierta", "title-color": "Color del título", - "display-device-selection": "Mostrar selección de dispositivo", + "display-dashboards-selection": "Mostrar selección del panel", + "display-entities-selection": "Mostrar selección de entidades", "display-dashboard-timewindow": "Mostrar ventana de tiempo", "display-dashboard-export": "Mostrar exportar", "import": "Importar panel", "export": "Exportar panel", - "export-failed-error": "Imposible exportar panel: {{error}}", + "export-failed-error": "No se puede exportar el panel: {{error}}", "create-new-dashboard": "Crear nuevo panel", "dashboard-file": "Archivo del panel", - "invalid-dashboard-file-error": "Imposible importar panel: Estructura de datos inválida.", - "dashboard-import-missing-aliases-title": "Configurar alias utilizados por el panel importado", + "invalid-dashboard-file-error": "No se puede importar el panel: estructura de datos del panel no es válida.", + "dashboard-import-missing-aliases-title": "Configurar los alias utilizados por el panel importado", "create-new-widget": "Crear nuevo widget", "import-widget": "Importar widget", - "widget-file": "Archivo de widget", - "invalid-widget-file-error": "Imposible importar widget: Estructura de datos inválida.", - "widget-import-missing-aliases-title": "Configurar alias utilizados por el widget", - "open-toolbar": "Abrir toolbar del panel", - "close-toolbar": "Cerrar toolbar", + "widget-file": "Archivo del widget", + "invalid-widget-file-error": "No se puede importar el widget: estructura de datos del widger no es válida.", + "widget-import-missing-aliases-title": "Configurar los alias utilizados por el widget importado", + "open-toolbar": "Abrir barra de herramientas del panel", + "close-toolbar": "Cerrar barra de herramientas", "configuration-error": "Error de configuración", - "alias-resolution-error-title": "Error de configuración de alias del panel", - "invalid-aliases-config": "No se puede encontrar ningún dispositivo que coincida con algunos de los alias de filtro.
Póngase en contacto con su administrador para resolver este problema.", + "alias-resolution-error-title": "Error de configuración de los alias del panel", + "invalid-aliases-config": "No se puede encontrar algún dispositivo que coincida con algunos alias del filtro.
Por favor, contacte a su administrador para resolver este problema.", "select-devices": "Seleccionar dispositivos", "assignedToCustomer": "Asignado al cliente", + "assignedToCustomers": "Asignado a los clientes", "public": "Público", - "public-link": "Link público", - "copy-public-link": "Copiar link público", - "public-link-copied-message": "El link público del panel se ha copiado al portapapeles" + "public-link": "Enlace público", + "copy-public-link": "Copiar enlace público", + "public-link-copied-message": "El enlace público del panel ha sido copiado al portapapeles", + "manage-states": "Gestionar estados del panel", + "states": "Estados del panel", + "search-states": "Buscar estados del panel", + "selected-states": "{ count, plural, 1 {1 dashboard state} other {# dashboard states} } seleccionados", + "edit-state": "Editar estado del panel", + "delete-state": "Eliminar estado del panel", + "add-state": "Agregar estado del panel", + "state": "Estado del panel", + "state-name": "Nombre", + "state-name-required": "El nombre del estado del panel es requerido.", + "state-id": "ID del estado", + "state-id-required": "ID del estado del panel es requerida.", + "state-id-exists": "Ya existe el estado del panel con el mismo ID.", + "is-root-state": "Estado raíz", + "delete-state-title": "Eliminar estado del panel", + "delete-state-text": "¿Está seguro de que desea eliminar el estado del panel con el nombre '{{stateName}}'?", + "show-details": "Mostrar detalles", + "hide-details": "Ocultar detalles", + "select-state": "Seleccionar estado objetivo", + "state-controller": "Estado del controlador" }, "datakey": { - "settings": "Ajustes", + "settings": "Configuración", "advanced": "Avanzado", "label": "Etiqueta", "color": "Color", + "units": "Símbolo especial para mostrar al lado del valor", + "decimals": "Número de dígitos después del punto flotante", "data-generation-func": "Función de generación de datos", - "use-data-post-processing-func": "Usar funcíon de post-procesamiendo de datos", - "configuration": "Ajustes de clave de datos", - "timeseries": "Serie de tiempos", + "use-data-post-processing-func": "Usar la función de post-procesamiento de datos", + "configuration": "Configuración de clave de datos", + "timeseries": "Series temporales", "attributes": "Atributos", - "timeseries-required": "Series de tiempo del dispositivo requerido.", - "timeseries-or-attributes-required": "Series de tiempo/Atributos requeridos.", + "alarm": "Campos de alarma", + "timeseries-required": "Series temporales de la entidad son requeridas", + "timeseries-or-attributes-required": "Series temporales/atributos de la entidad son requeridos.", + "maximum-timeseries-or-attributes": "Máximo { count, plural, 1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "alarm-fields-required": "Campos de alarma son requeridos.", "function-types": "Tipos de funciones", - "function-types-required": "Tipos de funciones requerido." + "function-types-required": "Tipos de funciones son requeridos.", + "maximum-function-types": "Máximo { count, plural, 1 {1 function type is allowed.} other {# function types are allowed} }" }, "datasource": { - "type": "Típo de fuente de datos", - "add-datasource-prompt": "Por favor, agrega una fuente de datos" + "type": "Tipo de fuente de datos", + "name": "Nombre", + "add-datasource-prompt": "Por favor agregue fuente de datos" }, "details": { - "edit-mode": "Modo Edición", - "toggle-edit-mode": "Ir a Modo Edición" + "details": "Detalles", + "edit-mode": "Modo de edición" }, "device": { "device": "Dispositivo", - "device-required": "Dispositivo requerido.", + "device-required": "Dispositivo es requerido.", "devices": "Dispositivos", - "management": "Gestión de Dispositivos", - "view-devices": "Ver dispositivos", - "device-alias": "Alias de dispositivo", - "aliases": "Alias de dispositivos", + "management": "Gestión del dispositivo", + "view-devices": "Ver Dispositivos", + "device-alias": "Alias del dispositivo", + "aliases": "Alias de los dispositivos", "no-alias-matching": "'{{alias}}' no encontrado.", - "no-aliases-found": "Ningún alias encontrado.", + "no-aliases-found": "Alias no encontrados.", "no-key-matching": "'{{key}}' no encontrado.", - "no-keys-found": "Ninguna clave encontrada.", - "create-new-alias": "Crear nuevo alias!", - "create-new-key": "Crear nueva clave!", - "duplicate-alias-error": "Alias duplicado '{{alias}}'.
El alias de los dispositivos deben ser únicos dentro del panel.", - "configure-alias": "Configurar alias '{{alias}}'", - "no-devices-matching": "No se encontró dispositivo '{{entity}}'", + "no-keys-found": "Claves no encontradas.", + "create-new-alias": "Crear uno nuevo!", + "create-new-key": "Crear una nueva!", + "duplicate-alias-error": "Alias duplicado encontrado '{{alias}}'.
Los alias del dispositivo deben ser únicos dentro del panel.", + "configure-alias": "Configurar '{{alias}}' alias", + "no-devices-matching": "Dispositivos que coincidan con '{{entity}}' no fueron encontrados.", "alias": "Alias", - "alias-required": "Alias de dispositivo requerido.", - "remove-alias": "Eliminar alias", - "add-alias": "Agregar alias", - "name-starts-with": "Nombre empieza con", + "alias-required": "Alias del dispositivo es requerido.", + "remove-alias": "Eliminar alias del dispositivo", + "add-alias": "Agregar alias del dispositivo", + "name-starts-with": "El nombre del dispositivo comienza con", "device-list": "Lista de dispositivos", - "use-device-name-filter": "Usar filtro", + "use-device-name-filter": "Utilizar filtro", "device-list-empty": "Ningún dispositivo seleccionado.", - "device-name-filter-required": "Nombre de filtro requerido.", - "device-name-filter-no-device-matched": "Ningún dispositivo encontrado que comience con '{{device}}'.", - "add": "Agregar dispositivo", - "assign-to-customer": "Asignar a cliente", - "assign-device-to-customer": "Asignar dispositivo(s) a Cliente", - "assign-device-to-customer-text": "Por favor, seleccione los dispositivos que serán asignados al cliente", - "make-public": "Hacer dispositivo público", - "make-private": "Hacer dispositivo privado", - "no-devices-text": "Ningún dispositivo encontrado", - "assign-to-customer-text": "Por favor, seleccione el cliente para asignar el(los) dispositivo(s)", + "device-name-filter-required": "Filtro de nombre de dispositivo es requerido.", + "device-name-filter-no-device-matched": "Dispositivos que comienzan con '{{device}}' no fueron encontrados.", + "add": "Agregar Dispositivo", + "assign-to-customer": "Asignar al cliente", + "assign-device-to-customer": "Asignar Dispositivo(s) Al Cliente", + "assign-device-to-customer-text": "Por favor seleccionar los dispositivos para asignar al cliente", + "make-public": "Hacer público el dispositivo", + "make-private": "Hacer privado el dispositivo", + "no-devices-text": "Dispositivos no encontrados", + "assign-to-customer-text": "Por favor seleccionar el cliente para asignar el(los) dispositivo(s)", "device-details": "Detalles del dispositivo", "add-device-text": "Agregar nuevo dispositivo", "credentials": "Credenciales", "manage-credentials": "Gestionar credenciales", "delete": "Eliminar dispositivo", - "assign-devices": "Asignar dispositivo", - "assign-devices-text": "Asignar { count, plural, 1 {1 dispositivo} other {# dispositivos} } al cliente", - "delete-devices": "Eliminar dispositivo", - "unassign-from-customer": "Desasignar del cliente", - "unassign-devices": "Desasignar dispositivos", - "unassign-devices-action-title": "Desasignar { count, plural, 1 {1 dispositivo} other {# dispositivos} } del cliente", + "assign-devices": "Asignar dispositivos", + "assign-devices-text": "Asignar { count, plural, 1 {1 device} other {# devices} } al cliente", + "delete-devices": "Eliminar dispositivos", + "unassign-from-customer": "Anular asignación del cliente", + "unassign-devices": "Anular asignación de dispositivos", + "unassign-devices-action-title": "Anular asignación { count, plural, 1 {1 device} other {# devices} } del cliente", "assign-new-device": "Asignar nuevo dispositivo", - "make-public-device-title": "¿Estás seguro que quieres hacer el dispositivo '{{deviceName}}' público?", - "make-public-device-text": "Luego de confirmar, el dispositivo y la información relacionada serán públicos y podrá ser accesible por otros.", - "make-private-device-title": "¿Estás seguro que quieres hacer el dispositivo '{{deviceName}}' privado?", - "make-private-device-text": "Luego de confirmar, el dispositivo y la información relacionada serán privados y no podrá ser accesible por otros.", + "make-public-device-title": "¿Está seguro de que desea hacer el dispositivo '{{deviceName}}' público?", + "make-public-device-text": "Después de la confirmación, el dispositivo y todos sus datos se harán públicos y accesibles por otros.", + "make-private-device-title": "¿Está seguro de que desea hacer el dispositivo '{{deviceName}}' privado?", + "make-private-device-text": "Después de la confirmación, el dispositivo y todos sus datos se harán privados y no serán accesibles para otros.", "view-credentials": "Ver credenciales", - "delete-device-title": "¿Estás seguro que quieres eliminar el dispositivo '{{deviceName}}'?", - "delete-device-text": "Ten cuidado, luego de confirmar los dispositivos serán eliminados y la información relacionada será irrecuperable.", - "delete-devices-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 dispositivo} other {# dispositivos} }?", - "delete-devices-action-title": "Eliminar { count, plural, 1 {1 dispositivo} other {# dispositivos} }", - "delete-devices-text": "Ten cuidado, luego de confirmar los dispositivos seleccionados serán eliminados y la información relacionada será irrecuperable.", - "unassign-device-title": "¿Estás seguro que quieres desasignar el dispositivo '{{deviceName}}'?", - "unassign-device-text": "Luego de confirmar el dispositivo será desasignado y no podrá ser accesible por el cliente.", - "unassign-device": "Desasignar dispositivo", - "unassign-devices-title": "¿Estás seguro que quieres desasignar { count, plural, 1 {1 dispositivo} other {# dispositivos} }?", - "unassign-devices-text": "Luego de confirmar los dispositivos seleccionados serán desasignados y no podrán ser accedidos por el cliente.", + "delete-device-title": "¿Está seguro de que desea hacer el dispositivo '{{deviceName}}'?", + "delete-device-text": "Tener cuidado, después de la confirmación, el dispositivo y todos sus datos relacionados se volverán irrecuperables.", + "delete-devices-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 device} other {# devices} }?", + "delete-devices-action-title": "Eliminar { count, plural, 1 {1 device} other {# devices} }", + "delete-devices-text": "Tener cuidado, después de la confirmación, todos los dispositivos seleccionados serán eliminados y todos los datos relacionados se volverán irrecuperables.", + "unassign-device-title": "¿Está seguro de que desea anular la asignación del dispositivo '{{deviceName}}'?", + "unassign-device-text": "Después de la confirmación, se anulará asignación del dispositivo y no será accesible por el cliente.", + "unassign-device": "Anular asignación del dispositivo", + "unassign-devices-title": "¿Está seguro de que desea anular asignación { count, plural, 1 {1 device} other {# devices} }?", + "unassign-devices-text": "Después de la confirmación, se anulará asignación de todos los dispositivos seleccionados y no serán accesibles por el cliente.", "device-credentials": "Credenciales del dispositivo", - "credentials-type": "Tipo de credencial", - "access-token": "Access token", - "access-token-required": "Access token requerido.", - "access-token-invalid": "Access token debe tener entre 1 a 20 caracteres.", + "credentials-type": "Tipo de credenciales", + "access-token": "Token de acceso", + "access-token-required": "Token de acceso es requerido.", + "access-token-invalid": "La longitud del token de acceso debe ser de 1 a 20 caracteres.", "rsa-key": "Clave pública RSA", - "rsa-key-required": "Clave pública RSA requerida.", - "secret": "Secreta", - "secret-required": "Secreta requerida.", + "rsa-key-required": "Clave pública RSA es requerida.", + "secret": "Secreto", + "secret-required": "Secreto es requerido.", + "device-type": "Tipo de dispositivo", + "device-type-required": "Tipo de dispositivo es requerido.", + "select-device-type": "Seleccionar tipo de dispositivo", + "enter-device-type": "Ingresar tipo de dispositivo", + "any-device": "Algún dispositivo", + "no-device-types-matching": "Tipos de dispositivos que coincidan con '{{entitySubtype}}' no fueron encontrados.", + "device-type-list-empty": "No se seleccionaron tipos de dispositivos.", + "device-types": "Tipo de dispositivos", "name": "Nombre", - "name-required": "Nombre requerido.", + "name-required": "El nombre es requerido.", "description": "Descripción", "events": "Eventos", "details": "Detalles", - "copyId": "Copiar ID", - "copyAccessToken": "Copiar access token", - "idCopiedMessage": "Id del dispositivo copiado al portapapeles", - "accessTokenCopiedMessage": "Access token del dispositivo copiado al portapapeles", + "copyId": "Copiar ID del dispositivo", + "copyAccessToken": "Copiar token de acceso", + "idCopiedMessage": "ID del dispositivo ha sido copiada al portapapeles", + "accessTokenCopiedMessage": "Token de acceso al dispositivo ha sido copiado al portapapeles", "assignedToCustomer": "Asignado al cliente", - "unable-delete-device-alias-title": "Imposible eliminar alias del dispositivo", - "unable-delete-device-alias-text": "Alias '{{deviceAlias}}' no puede ser eliminado. Esta siendo usado por el(los) widget(s):
{{widgetsList}}", - "is-gateway": "Es gateway", + "unable-delete-device-alias-title": "No se puede eliminar el alias del dispositivo", + "unable-delete-device-alias-text": "El alias del dispositivo '{{deviceAlias}}' no puede ser eliminado porque es usado por los siguientes widget(s):
{{widgetsList}}", + "is-gateway": "Es puerta de entrada", "public": "Público", - "device-public": "Dispositivo público" + "device-public": "El dispositivo es público", + "select-device": "Seleccinar dispositivo" }, "dialog": { - "close": "Cerrar cuadro de diálogo" + "close": "Cerrar diálogo" }, "error": { - "unable-to-connect": "Imposible conectar con el servidor! Por favor, revise su conexión a internet.", - "unhandled-error-code": "Código de error no manejado: {{errorCode}}", + "unable-to-connect": "No se puede conectar al servidor! Por favor revise su conexión a Internet.", + "unhandled-error-code": "Código de error no controlado: {{errorCode}}", "unknown-error": "Error desconocido" }, "entity": { - "entity": "Entity", - "entities": "Entities", - "aliases": "Entity aliases", - "entity-alias": "Entity alias", - "unable-delete-entity-alias-title": "Unable to delete entity alias", - "unable-delete-entity-alias-text": "Entity alias '{{entityAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", - "duplicate-alias-error": "Duplicate alias found '{{alias}}'.
Entity aliases must be unique whithin the dashboard.", - "missing-entity-filter-error": "Filter is missing for alias '{{alias}}'.", - "configure-alias": "Configure '{{alias}}' alias", + "entity": "Entidad", + "entities": "Entidades", + "aliases": "Alias de las entidades", + "entity-alias": "Alias de la entidad", + "unable-delete-entity-alias-title": "No se puede borrar alias de la entidad", + "unable-delete-entity-alias-text": "Alias de la entidad '{{entityAlias}}' no piuede ser eliminado porque es usado por los siguientes widget(s):
{{widgetsList}}", + "duplicate-alias-error": "Alias duplicado fue encontrado '{{alias}}'.
Alias de las entidades deben ser únicos dentro del panel.", + "missing-entity-filter-error": "Falta filtro para el alias '{{alias}}'.", + "configure-alias": "Configurar '{{alias}}' alias", "alias": "Alias", - "alias-required": "Entity alias is required.", - "remove-alias": "Remove entity alias", - "add-alias": "Add entity alias", - "entity-list": "Entity list", - "entity-type": "Entity type", - "entity-types": "Entity types", - "entity-type-list": "Entity type list", - "any-entity": "Any entity", - "enter-entity-type": "Enter entity type", - "no-entities-matching": "No entities matching '{{entity}}' were found.", - "no-entity-types-matching": "No entity types matching '{{entityType}}' were found.", - "name-starts-with": "Name starts with", - "use-entity-name-filter": "Use filter", - "entity-list-empty": "No entities selected.", - "entity-type-list-empty": "No entity types selected.", - "entity-name-filter-required": "Entity name filter is required.", - "entity-name-filter-no-entity-matched": "No entities starting with '{{entity}}' were found.", - "all-subtypes": "All", - "select-entities": "Select entities", - "no-aliases-found": "No aliases found.", - "no-alias-matching": "'{{alias}}' not found.", - "create-new-alias": "Create a new one!", - "key": "Key", - "key-name": "Key name", - "no-keys-found": "No keys found.", - "no-key-matching": "'{{key}}' not found.", - "create-new-key": "Create a new one!", - "type": "Type", - "type-required": "Entity type is required.", - "type-device": "Device", - "type-devices": "Devices", + "alias-required": "Alias de la entidad es requerida.", + "remove-alias": "Eliminar alias de la entidad", + "add-alias": "Agregar alias de la entidad", + "entity-list": "Lista de entidades", + "entity-type": "Tipo de entidad", + "entity-types": "Tipos de entidades", + "entity-type-list": "Lista de tipos de entidades", + "any-entity": "Alguna entidad", + "enter-entity-type": "Ingresara tipo de entidad", + "no-entities-matching": "Entidades que coincidan con '{{entity}}' no fueron encontradas.", + "no-entity-types-matching": "Tipos de entidades que coincidan con '{{entityType}}' no fueron encontrados.", + "name-starts-with": "El nombre comienza con", + "use-entity-name-filter": "Utilizar filtro", + "entity-list-empty": "Entidades no seleccionadas.", + "entity-type-list-empty": "Tipos de entidades no seleccionados.", + "entity-name-filter-required": "Filtro del nombre de la entidad es requerido.", + "entity-name-filter-no-entity-matched": "Entidades que comienzan con '{{entity}}' no fueron encontradas.", + "all-subtypes": "Todas", + "select-entities": "Seleccionar entidades", + "no-aliases-found": "Alias no encontrados.", + "no-alias-matching": "'{{alias}}' no encontrado.", + "create-new-alias": "Crear uno nuevo!", + "key": "Clave", + "key-name": "Nombre de clave", + "no-keys-found": "Claves no encontradas.", + "no-key-matching": "'{{key}}' no encontrada.", + "create-new-key": "Crear una nueva!", + "type": "Tipo", + "type-required": "Tipo de entidad es requerido.", + "type-device": "Dispositivo", + "type-devices": "Dispositivos", "list-of-devices": "{ count, plural, 1 {One device} other {List of # devices} }", - "device-name-starts-with": "Devices whose names start with '{{prefix}}'", - "type-asset": "Asset", - "type-assets": "Assets", + "device-name-starts-with": "Dispositivos cuyos nombres comienzan con '{{prefix}}'", + "type-asset": "Activo", + "type-assets": "Activos", "list-of-assets": "{ count, plural, 1 {One asset} other {List of # assets} }", - "asset-name-starts-with": "Assets whose names start with '{{prefix}}'", - "type-rule": "Rule", - "type-rules": "Rules", + "asset-name-starts-with": "Activos cuyos nombres comienzan con '{{prefix}}'", + "type-entity-view": "Entity View", + "type-entity-views": "Entity Views", + "list-of-entity-views": "{ count, plural, 1 {One entity view} other {List of # entity views} }", + "entity-view-name-starts-with": "Entity Views whose names start with '{{prefix}}'", + "type-rule": "Regla", + "type-rules": "Reglas", "list-of-rules": "{ count, plural, 1 {One rule} other {List of # rules} }", - "rule-name-starts-with": "Rules whose names start with '{{prefix}}'", - "type-plugin": "Plugin", - "type-plugins": "Plugins", + "rule-name-starts-with": "Reglas cuyos nombres comienzan con '{{prefix}}'", + "type-plugin": "Complemento", + "type-plugins": "Complementos", "list-of-plugins": "{ count, plural, 1 {One plugin} other {List of # plugins} }", - "plugin-name-starts-with": "Plugins whose names start with '{{prefix}}'", - "type-tenant": "Tenant", - "type-tenants": "Tenants", + "plugin-name-starts-with": "Complementos cuyos nombres comienzan con '{{prefix}}'", + "type-tenant": "Organización", + "type-tenants": "Organizaciones", "list-of-tenants": "{ count, plural, 1 {One tenant} other {List of # tenants} }", - "tenant-name-starts-with": "Tenants whose names start with '{{prefix}}'", - "type-customer": "Customer", - "type-customers": "Customers", + "tenant-name-starts-with": "Organizaciones cuyos nombres comienzan con '{{prefix}}'", + "type-customer": "Cliente", + "type-customers": "Clientes", "list-of-customers": "{ count, plural, 1 {One customer} other {List of # customers} }", - "customer-name-starts-with": "Customers whose names start with '{{prefix}}'", - "type-user": "User", - "type-users": "Users", + "customer-name-starts-with": "Clientes cuyos nombres comienzan con '{{prefix}}'", + "type-user": "Usuario", + "type-users": "Usuarios", "list-of-users": "{ count, plural, 1 {One user} other {List of # users} }", - "user-name-starts-with": "Users whose names start with '{{prefix}}'", - "type-dashboard": "Dashboard", - "type-dashboards": "Dashboards", + "user-name-starts-with": "Usuarios cuyos nombres comienzan con '{{prefix}}'", + "type-dashboard": "Panel", + "type-dashboards": "Paneles", "list-of-dashboards": "{ count, plural, 1 {One dashboard} other {List of # dashboards} }", - "dashboard-name-starts-with": "Dashboards whose names start with '{{prefix}}'", - "type-alarm": "Alarm", - "type-alarms": "Alarms", + "dashboard-name-starts-with": "Paneles cuyos nombres comienzan con '{{prefix}}'", + "type-alarm": "Alarma", + "type-alarms": "Alarmas", "list-of-alarms": "{ count, plural, 1 {One alarms} other {List of # alarms} }", - "alarm-name-starts-with": "Alarms whose names start with '{{prefix}}'", - "type-rulechain": "Rule chain", - "type-rulechains": "Rule chains", + "alarm-name-starts-with": "Alarmas cuyos nombres comienzan con '{{prefix}}'", + "type-rulechain": "Cadena de reglas", + "type-rulechains": "Cadenas de reglas", "list-of-rulechains": "{ count, plural, 1 {One rule chain} other {List of # rule chains} }", - "rulechain-name-starts-with": "Rule chains whose names start with '{{prefix}}'", - "type-current-customer": "Current Customer", - "search": "Search entities", - "selected-entities": "{ count, plural, 1 {1 entity} other {# entities} } selected", - "entity-name": "Entity name", - "details": "Entity details", - "no-entities-prompt": "No entities found", - "no-data": "No data to display" + "rulechain-name-starts-with": "Cadenas reglas cuyos nombres comienzan con '{{prefix}}'", + "type-rulenode": "Nodo de reglas", + "type-rulenodes": "Nodos de reglas", + "list-of-rulenodes": "{ count, plural, 1 {One rule node} other {List of # rule nodes} }", + "rulenode-name-starts-with": "Nodos de reglas cuyos nombres comienzan con '{{prefix}}'", + "type-current-customer": "Cliente Actual", + "search": "Buscar entidades", + "selected-entities": "{ count, plural, 1 {1 entity} other {# entities} } seleccionadas", + "entity-name": "Nombre de la entidad", + "details": "Detalles de la entidad", + "no-entities-prompt": "Entidades no encontradas", + "no-data": "No hay datos para mostrar", + "columns-to-display": "Columns to Display" + }, + "entity-view": { + "entity-view": "Entity View", + "entity-views": "Entity Views", + "management": "Entity View management", + "view-entity-views": "View Entity Views", + "entity-view-alias": "Entity View alias", + "aliases": "Entity View aliases", + "no-alias-matching": "'{{alias}}' not found.", + "no-aliases-found": "No aliases found.", + "no-key-matching": "'{{key}}' not found.", + "no-keys-found": "No keys found.", + "create-new-alias": "Create a new one!", + "create-new-key": "Create a new one!", + "duplicate-alias-error": "Duplicate alias found '{{alias}}'.
Entity View aliases must be unique whithin the dashboard.", + "configure-alias": "Configure '{{alias}}' alias", + "no-entity-views-matching": "No entity views matching '{{entity}}' were found.", + "alias": "Alias", + "alias-required": "Entity View alias is required.", + "remove-alias": "Remove entity view alias", + "add-alias": "Add entity view alias", + "name-starts-with": "Entity View name starts with", + "entity-view-list": "Entity View list", + "use-entity-view-name-filter": "Use filter", + "entity-view-list-empty": "No entity views selected.", + "entity-view-name-filter-required": "Entity view name filter is required.", + "entity-view-name-filter-no-entity-view-matched": "No entity views starting with '{{entityView}}' were found.", + "add": "Add Entity View", + "assign-to-customer": "Assign to customer", + "assign-entity-view-to-customer": "Assign Entity View(s) To Customer", + "assign-entity-view-to-customer-text": "Please select the entity views to assign to the customer", + "no-entity-views-text": "No entity views found", + "assign-to-customer-text": "Please select the customer to assign the entity view(s)", + "entity-view-details": "Entity view details", + "add-entity-view-text": "Add new entity view", + "delete": "Delete entity view", + "assign-entity-views": "Assign entity views", + "assign-entity-views-text": "Assign { count, plural, 1 {1 entityView} other {# entityViews} } to customer", + "delete-entity-views": "Delete entity views", + "unassign-from-customer": "Unassign from customer", + "unassign-entity-views": "Unassign entity views", + "unassign-entity-views-action-title": "Unassign { count, plural, 1 {1 entityView} other {# entityViews} } from customer", + "assign-new-entity-view": "Assign new entity view", + "delete-entity-view-title": "Are you sure you want to delete the entity view '{{entityViewName}}'?", + "delete-entity-view-text": "Be careful, after the confirmation the entity view and all related data will become unrecoverable.", + "delete-entity-views-title": "Are you sure you want to entity view { count, plural, 1 {1 entityView} other {# entityViews} }?", + "delete-entity-views-action-title": "Delete { count, plural, 1 {1 entityView} other {# entityViews} }", + "delete-entity-views-text": "Be careful, after the confirmation all selected entity views will be removed and all related data will become unrecoverable.", + "unassign-entity-view-title": "Are you sure you want to unassign the entity view '{{entityViewName}}'?", + "unassign-entity-view-text": "After the confirmation the entity view will be unassigned and won't be accessible by the customer.", + "unassign-entity-view": "Unassign entity view", + "unassign-entity-views-title": "Are you sure you want to unassign { count, plural, 1 {1 entityView} other {# entityViews} }?", + "unassign-entity-views-text": "After the confirmation all selected entity views will be unassigned and won't be accessible by the customer.", + "entity-view-type": "Entity View type", + "entity-view-type-required": "Entity View type is required.", + "select-entity-view-type": "Select entity view type", + "enter-entity-view-type": "Enter entity view type", + "any-entity-view": "Any entity view", + "no-entity-view-types-matching": "No entity view types matching '{{entitySubtype}}' were found.", + "entity-view-type-list-empty": "No entity view types selected.", + "entity-view-types": "Entity View types", + "name": "Name", + "name-required": "Name is required.", + "description": "Description", + "events": "Events", + "details": "Details", + "copyId": "Copy entity view Id", + "assignedToCustomer": "Assigned to customer", + "unable-entity-view-device-alias-title": "Unable to delete entity view alias", + "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", + "select-entity-view": "Select entity view", + "make-public": "Make entity view public", + "start-ts": "Start time", + "end-ts": "End time", + "date-limits": "Date limits", + "client-attributes": "Client attributes", + "shared-attributes": "Shared attributes", + "server-attributes": "Server attributes", + "timeseries": "Timeseries" }, "event": { "event-type": "Tipo de evento", "type-error": "Error", - "type-lc-event": "Ciclo de vida", + "type-lc-event": "Ciclo de vida del evento", "type-stats": "Estadísticas", - "no-events-prompt": "Ningún evento encontrado.", + "type-debug-rule-node": "Depurar", + "type-debug-rule-chain": "Depurar", + "no-events-prompt": "Eventos no encontrados", "error": "Error", "alarm": "Alarma", - "event-time": "Hora del evento", + "event-time": "Tiempo del evento", "server": "Servidor", "body": "Cuerpo", "method": "Método", + "type": "Tipo", + "entity": "Entidad", + "message-id": "ID del mensaje", + "message-type": "Tipo de mensaje", + "data-type": "Tipo de datos", + "relation-type": "Tipo de relación", + "metadata": "Metadatos", + "data": "Datos", "event": "Evento", - "status": "Status", + "status": "Estado", "success": "Éxito", - "failed": "Fallo", + "failed": "Falla", "messages-processed": "Mensajes procesados", - "errors-occurred": "Ocurrieron errores" + "errors-occurred": "Errores ocurridos" }, "extension": { - "extensions": "Extensions", - "selected-extensions": "{ count, plural, 1 {1 extension} other {# extensions} } selected", - "type": "Type", - "key": "Key", - "value": "Value", - "id": "Id", - "extension-id": "Extension id", - "extension-type": "Extension type", + "extensions": "Extensiones", + "selected-extensions": "{ count, plural, 1 {1 extension} other {# extensions} } seleccionadas", + "type": "Tipo", + "key": "Clave", + "value": "Valor", + "id": "ID", + "extension-id": "ID de extensión", + "extension-type": "Tipo de extensión", "transformer-json": "JSON *", - "unique-id-required": "Current extension id already exists.", - "delete": "Delete extension", - "add": "Add extension", - "edit": "Edit extension", - "delete-extension-title": "Are you sure you want to delete the extension '{{extensionId}}'?", - "delete-extension-text": "Be careful, after the confirmation the extension and all related data will become unrecoverable.", - "delete-extensions-title": "Are you sure you want to delete { count, plural, 1 {1 extension} other {# extensions} }?", - "delete-extensions-text": "Be careful, after the confirmation all selected extensions will be removed.", - "converters": "Converters", - "converter-id": "Converter id", - "configuration": "Configuration", - "converter-configurations": "Converter configurations", - "token": "Security token", - "add-converter": "Add converter", - "add-config": "Add converter configuration", - "device-name-expression": "Device name expression", - "device-type-expression": "Device type expression", - "custom": "Custom", - "to-double": "To Double", - "transformer": "Transformer", - "json-required": "Transformer json is required.", - "json-parse": "Unable to parse transformer json.", - "attributes": "Attributes", - "add-attribute": "Add attribute", - "add-map": "Add mapping element", - "timeseries": "Timeseries", - "add-timeseries": "Add timeseries", - "field-required": "Field is required", - "brokers": "Brokers", - "add-broker": "Add broker", + "unique-id-required": "Ya existe ID de extensión actual.", + "delete": "Eliminar extensión", + "add": "Agregar extensión", + "edit": "Editar extensión", + "delete-extension-title": "¿Está seguro de que desea eliminar la extensión '{{extensionId}}'?", + "delete-extension-text": "Tener cuidado, después de la confirmación, la extensión y todos los datos relacionados se volverán irrecuperables.", + "delete-extensions-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 extension} other {# extensions} }?", + "delete-extensions-text": "Tener cuidado, después de la confirmación, se eliminarán todas las extensiones seleccionadas.", + "converters": "Conversores", + "converter-id": "ID del conversor", + "configuration": "Configuración", + "converter-configurations": "Configuraciones del conversor", + "token": "Token de seguridad", + "add-converter": "Agregar conversor", + "add-config": "Configuración para agregar conversor", + "device-name-expression": "Expresión del nombre del dispositivo", + "device-type-expression": "Expresión del tipo del dispositivo", + "custom": "Personalizado", + "to-double": "Para duplicar", + "transformer": "Transformador", + "json-required": "Transformador json es requerido.", + "json-parse": "No se puede analizar el transformador json.", + "attributes": "Atributos", + "add-attribute": "Agregar atributos", + "add-map": "Agregar elemento de mapeo", + "timeseries": "Series temporales", + "add-timeseries": "Agregar series temporales", + "field-required": "Campo es requerido", + "brokers": "Agentes", + "add-broker": "Agregar agente", "host": "Host", - "port": "Port", - "port-range": "Port should be in a range from 1 to 65535.", - "ssl": "Ssl", - "credentials": "Credentials", - "username": "Username", - "password": "Password", - "retry-interval": "Retry interval in milliseconds", - "anonymous": "Anonymous", - "basic": "Basic", + "port": "Puerto", + "port-range": "El puerto debe estar en un rango desde 1 hasta 65535.", + "ssl": "SSL", + "credentials": "Credenciales", + "username": "Nombre de usuario", + "password": "Contraseña", + "retry-interval": "Intervalo de reintento en milisegundos", + "anonymous": "Anónimo", + "basic": "Básico", "pem": "PEM", - "ca-cert": "CA certificate file *", - "private-key": "Private key file *", - "cert": "Certificate file *", - "no-file": "No file selected.", - "drop-file": "Drop a file or click to select a file to upload.", - "mapping": "Mapping", - "topic-filter": "Topic filter", - "converter-type": "Converter type", + "ca-cert": "Archivo de certificado CA *", + "private-key": "Archivo de clave privado *", + "cert": "Archivo de certificado *", + "no-file": "Ningún archivo seleccionado.", + "drop-file": "Colocar un archivo o hacer clic para seleccionar un archivo para cargar.", + "mapping": "Mapeo", + "topic-filter": "Filtro de tema", + "converter-type": "Tipo de conversor", "converter-json": "Json", - "json-name-expression": "Device name json expression", - "topic-name-expression": "Device name topic expression", - "json-type-expression": "Device type json expression", - "topic-type-expression": "Device type topic expression", - "attribute-key-expression": "Attribute key expression", - "attr-json-key-expression": "Attribute key json expression", - "attr-topic-key-expression": "Attribute key topic expression", - "request-id-expression": "Request id expression", - "request-id-json-expression": "Request id json expression", - "request-id-topic-expression": "Request id topic expression", - "response-topic-expression": "Response topic expression", - "value-expression": "Value expression", - "topic": "Topic", - "timeout": "Timeout in milliseconds", - "converter-json-required": "Converter json is required.", - "converter-json-parse": "Unable to parse converter json.", - "filter-expression": "Filter expression", - "connect-requests": "Connect requests", - "add-connect-request": "Add connect request", - "disconnect-requests": "Disconnect requests", - "add-disconnect-request": "Add disconnect request", - "attribute-requests": "Attribute requests", - "add-attribute-request": "Add attribute request", - "attribute-updates": "Attribute updates", - "add-attribute-update": "Add attribute update", - "server-side-rpc": "Server side RPC", - "add-server-side-rpc-request": "Add server-side RPC request", - "device-name-filter": "Device name filter", - "attribute-filter": "Attribute filter", - "method-filter": "Method filter", - "request-topic-expression": "Request topic expression", - "response-timeout": "Response timeout in milliseconds", - "topic-expression": "Topic expression", - "client-scope": "Client scope", - "add-device": "Add device", - "opc-server": "Servers", - "opc-add-server": "Add server", - "opc-add-server-prompt": "Please add server", - "opc-application-name": "Application name", - "opc-application-uri": "Application uri", - "opc-scan-period-in-seconds": "Scan period in seconds", - "opc-security": "Security", - "opc-identity": "Identity", - "opc-keystore": "Keystore", - "opc-type": "Type", - "opc-keystore-type": "Type", - "opc-keystore-location": "Location *", - "opc-keystore-password": "Password", + "json-name-expression": "Expresión json para nombre del dispositivo", + "topic-name-expression": "Expresión temática para nombre del dispositivo", + "json-type-expression": "Expresión json para tipo de dispositivo", + "topic-type-expression": "Expresión temática para tipo de dispositivo", + "attribute-key-expression": "Expresión para clave de atributo", + "attr-json-key-expression": "Expresión json para clave de atributo", + "attr-topic-key-expression": "Expresión temática para clave de atributo", + "request-id-expression": "Expresión para solicitud de ID", + "request-id-json-expression": "Expresión json para solicitud de ID", + "request-id-topic-expression": "Expresión temática para solicitud de ID", + "response-topic-expression": "Expresión temática para respuesta", + "value-expression": "Expresión para valor", + "topic": "Tema", + "timeout": "Tiempo de espera en milisegundos", + "converter-json-required": "Conversor json es requerido.", + "converter-json-parse": "No se puede analizar el conversor json.", + "filter-expression": "Expresión para filtro", + "connect-requests": "Solicitudes de conexión", + "add-connect-request": "Agregar solicitudes de conexión", + "disconnect-requests": "Solicitudes de desconexión", + "add-disconnect-request": "Agregar solicitudes de desconexión", + "attribute-requests": "Solicitudes de atributo", + "add-attribute-request": "Agregar solicitudes de atributo", + "attribute-updates": "Actualizaciones de atributo", + "add-attribute-update": "Agregar actualizaciones de atributo", + "server-side-rpc": "RPC lado servidor", + "add-server-side-rpc-request": "Agregar solicitud RPC lado servidor", + "device-name-filter": "Filtro de nombre de dispositivo", + "attribute-filter": "Filtro de atributo", + "method-filter": "Filtro de método", + "request-topic-expression": "Expresión temática para solicitud", + "response-timeout": "Respuesta a tiempo de espera en milisegundos", + "topic-expression": "Expresión temática", + "client-scope": "Alcance del cliente", + "add-device": "Agregar dispositivo", + "opc-server": "Servidores", + "opc-add-server": "Agregar servidor", + "opc-add-server-prompt": "Por favor agregar servidor", + "opc-application-name": "Nombre de aplicación", + "opc-application-uri": "Aplicación URI", + "opc-scan-period-in-seconds": "Período de exploración en segundos", + "opc-security": "Seguridad", + "opc-identity": "Identidad", + "opc-keystore": "Repositorio", + "opc-type": "Tipo", + "opc-keystore-type": "Tipo", + "opc-keystore-location": "Ubicación *", + "opc-keystore-password": "Contraseña", "opc-keystore-alias": "Alias", - "opc-keystore-key-password": "Key password", - "opc-device-node-pattern": "Device node pattern", - "opc-device-name-pattern": "Device name pattern", - "modbus-server": "Servers/slaves", - "modbus-add-server": "Add server/slave", - "modbus-add-server-prompt": "Please add server/slave", - "modbus-transport": "Transport", - "modbus-port-name": "Serial port name", - "modbus-encoding": "Encoding", - "modbus-parity": "Parity", - "modbus-baudrate": "Baud rate", - "modbus-databits": "Data bits", - "modbus-stopbits": "Stop bits", - "modbus-databits-range": "Data bits should be in a range from 7 to 8.", - "modbus-stopbits-range": "Stop bits should be in a range from 1 to 2.", - "modbus-unit-id": "Unit ID", - "modbus-unit-id-range": "Unit ID should be in a range from 1 to 247.", - "modbus-device-name": "Device name", - "modbus-poll-period": "Poll period (ms)", - "modbus-attributes-poll-period": "Attributes poll period (ms)", - "modbus-timeseries-poll-period": "Timeseries poll period (ms)", - "modbus-poll-period-range": "Poll period should be positive value.", - "modbus-tag": "Tag", - "modbus-function": "Function", - "modbus-register-address": "Register address", - "modbus-register-address-range": "Register address should be in a range from 0 to 65535.", - "modbus-register-bit-index": "Bit index", - "modbus-register-bit-index-range": "Bit index should be in a range from 0 to 15.", - "modbus-register-count": "Register count", - "modbus-register-count-range": "Register count should be a positive value.", - "modbus-byte-order": "Byte order", + "opc-keystore-key-password": "Clave de contraseña", + "opc-device-node-pattern": "Patrón de nodo de dispostivo", + "opc-device-name-pattern": "Patrón de nombre de dispositivo", + "modbus-server": "Servidores/esclavos", + "modbus-add-server": "Agregar servidor/esclavo", + "modbus-add-server-prompt": "Por favor agregar servidor/esclavo", + "modbus-transport": "Transporte", + "modbus-port-name": "Nombre del puerto serial", + "modbus-encoding": "Codificación", + "modbus-parity": "Paridad", + "modbus-baudrate": "Tasa de baudios", + "modbus-databits": "Bits de datos", + "modbus-stopbits": "Bits de parada", + "modbus-databits-range": "Bits de datos deben estar en un rango entre 7 y 8.", + "modbus-stopbits-range": "Bits de parada deben estar en un rango entre 1 a 2.", + "modbus-unit-id": "ID de unidad", + "modbus-unit-id-range": "ID de unidad debe estar en un rango entre 1 a 247.", + "modbus-device-name": "Nombre del dispositivo", + "modbus-poll-period": "Período de sondeo (ms)", + "modbus-attributes-poll-period": "Atributos del período de sondeo (ms)", + "modbus-timeseries-poll-period": "Período de sondeo de las series temporales (ms)", + "modbus-poll-period-range": "El período de sondeo debe ser una valor positivo.", + "modbus-tag": "Etiqueta", + "modbus-function": "Función", + "modbus-register-address": "Dirección del registro", + "modbus-register-address-range": "Dirección del registro debe estar en un rango entre 0 y 65535.", + "modbus-register-bit-index": "Índice de bit", + "modbus-register-bit-index-range": "Índice de bit debe estar en un rango entre 0 y 15.", + "modbus-register-count": "Contador del registro", + "modbus-register-count-range": "Contador del registro debe ser un valor positivo.", + "modbus-byte-order": "Orden del byte", "sync": { - "status": "Status", - "sync": "Sync", - "not-sync": "Not sync", - "last-sync-time": "Last sync time", - "not-available": "Not available" + "status": "Estado", + "sync": "Sincronización", + "not-sync": "No sincronización", + "last-sync-time": "Último tiempo de sincroniación", + "not-available": "No disponible" }, - "export-extensions-configuration": "Export extensions configuration", - "import-extensions-configuration": "Import extensions configuration", - "import-extensions": "Import extensions", - "import-extension": "Import extension", - "export-extension": "Export extension", - "file": "Extensions file", - "invalid-file-error": "Invalid extension file" + "export-extensions-configuration": "Exportar configuración de extensiones", + "import-extensions-configuration": "Importar configuración de extensiones", + "import-extensions": "Importar extensiones", + "import-extension": "Importar extensión", + "export-extension": "Exportar extensión", + "file": "Archivo de extensiones", + "invalid-file-error": "Archivo de extensión no válido" }, "fullscreen": { - "expand": "Expandir a Pantalla Completa", - "exit": "Salir de Pantalla Completa", - "toggle": "Cambiar el modo de Pantalla Completa", - "fullscreen": "Pantalla Completa" + "expand": "Expandir a pantalla completa", + "exit": "Salir de pantalla completa", + "toggle": "Alternar el modo de pantalla completa", + "fullscreen": "Pantalla completa" }, "function": { "function": "Función" }, "grid": { - "delete-item-title": "¿Estás seguro que quieres eliminar este item?", - "delete-item-text": "Ten cuidado, luego de confirmar el item será eliminado y la información relacionada será irrecuperable.", - "delete-items-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 item} other {# items} }?", + "delete-item-title": "¿Está seguro de que desea eliminar este ítem?", + "delete-item-text": "Tener cuidado, después de la confirmación, este ítem y todos los datos relacionados se volverán irrecuperables.", + "delete-items-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 item} other {# items} }?", "delete-items-action-title": "Eliminar { count, plural, 1 {1 item} other {# items} }", - "delete-items-text": "Ten cuidado, luego de confirmar los items seleccionados serán eliminados y la información relacionada será irrecuperable.", - "add-item-text": "Agregar nuevo item", - "no-items-text": "Ningún item encontrado", - "item-details": "Detalles del item", - "delete-item": "Borrar Item", - "delete-items": "Borrar Items", - "scroll-to-top": "Ir hacia arriba" + "delete-items-text": "Tener cuidado, después de la confirmación se eliminarán todos los ítems seleccionados y todos los datos relacionados se volverán irrecuperables.", + "add-item-text": "Agregar nuevo ítem", + "no-items-text": "Ítems no encontrados", + "item-details": "Detalles del ítem", + "delete-item": "Eliminar ítem", + "delete-items": "Eliminar ítems", + "scroll-to-top": "Desplazar al inicio" }, "help": { - "goto-help-page": "Ir a Página de Ayuda" + "goto-help-page": "Ir a la página de ayuda" }, "home": { - "home": "Principal", + "home": "Página principal", "profile": "Perfil", - "logout": "Salir", - "menu": "Menu", + "logout": "Cerrar sesión", + "menu": "Menú", "avatar": "Avatar", "open-user-menu": "Abrir menú de usuario" }, "import": { "no-file": "Ningún archivo seleccionado", - "drop-file": "Arrastra un archivo JSON o clickea para seleccionar uno." + "drop-file": "Colocar un archivo JSON o hacer clic para seleccionar un archivo para cargar." }, "item": { "selected": "Seleccionado" }, "js-func": { - "no-return-error": "La función debe retornar un valor!", - "return-type-mismatch": "La función debe retornar un valor de tipo: '{{type}}'!" + "no-return-error": "La función debe devolver el valor!", + "return-type-mismatch": "La función debe devolver el valor de '{{type}}' type!", + "tidy": "Ordenado" }, "key-val": { - "key": "Key", - "value": "Value", - "remove-entry": "Remove entry", - "add-entry": "Add entry", - "no-data": "No entries" + "key": "Clave", + "value": "Valor", + "remove-entry": "Eliminar entrada", + "add-entry": "Agregar entrada", + "no-data": "Ninguna entrada" }, "layout": { - "layout": "Layout", - "manage": "Manage layouts", - "settings": "Layout settings", + "layout": "Diseño", + "manage": "Gestionar diseños", + "settings": "Configuaración de diseño", "color": "Color", - "main": "Main", - "right": "Right", - "select": "Select target layout" + "main": "Principal", + "right": "Derecha", + "select": "Seleccionar diseño objetivo" }, "legend": { - "position": "Posición de leyenda", - "show-max": "Mostrar máximo", - "show-min": "Mostrar mínimo", - "show-avg": "Mostrar promedio", - "show-total": "Mostrar total", - "settings": "Ajustes de leyenda.", - "min": "min", - "max": "max", - "avg": "prom", + "position": "Posición de la leyenda", + "show-max": "Mostrar valor máximo", + "show-min": "Mostrar valor mínimo", + "show-avg": "Mostrar valor promedio", + "show-total": "Mostrar valor total", + "settings": "Configuración de la leyenda", + "min": "mínimo", + "max": "máximo", + "avg": "promedio", "total": "total" }, "login": { - "login": "Ingresar", - "request-password-reset": "Pedir restablecer contraseña", - "reset-password": "Restablecer contraseña", - "create-password": "Crear contraseña", - "passwords-mismatch-error": "Las contraseñas deben ser las mismas!", - "password-again": "Reingresa la contraseña", - "sign-in": "Iniciar sesión", - "username": "Usuario (email)", - "remember-me": "Recordar", - "forgot-password": "¿Olvidaste tu contraseña?", - "password-reset": "Restablecer Contraseña", + "login": "Iniciar sesión", + "request-password-reset": "Solicitar Restablecimiento Contraseña", + "reset-password": "Restablecer Contraseña", + "create-password": "Crear Contraseña", + "passwords-mismatch-error": "Las contraseñas introducidas deben ser las mismas!", + "password-again": "Contraseña nuevamente", + "sign-in": "Por favor registrarse", + "username": "Nombre de usuario (correo electrónico)", + "remember-me": "Recordarme", + "forgot-password": "Olvidó Contraseña?", + "password-reset": "Restablecimiento de Contraseña", "new-password": "Nueva contraseña", - "new-password-again": "Repita la nueva contraseña", - "password-link-sent-message": "Se ha enviado el enlace de restablecimiento de contraseña con éxito!", - "email": "Email" + "new-password-again": "Nueva contraseña otra vez", + "password-link-sent-message": "El enlace para el restablecimieneto de la contraseña fue enviado exitosamente!", + "email": "Correo electrónico" }, "position": { - "top": "Arriba", - "bottom": "Abajo", + "top": "Superior", + "bottom": "Inferior", "left": "Izquierda", "right": "Derecha" }, @@ -946,163 +1129,179 @@ "current-password": "Contraseña actual" }, "relation": { - "relations": "Relations", - "direction": "Direction", + "relations": "Relaciones", + "direction": "Dirección", "search-direction": { - "FROM": "From", - "TO": "To" + "FROM": "Desde", + "TO": "Hacia" }, "direction-type": { - "FROM": "from", - "TO": "to" + "FROM": "desde", + "TO": "hacia" }, - "from-relations": "Outbound relations", - "to-relations": "Inbound relations", - "selected-relations": "{ count, plural, 1 {1 relation} other {# relations} } selected", - "type": "Type", - "to-entity-type": "To entity type", - "to-entity-name": "To entity name", - "from-entity-type": "From entity type", - "from-entity-name": "From entity name", - "to-entity": "To entity", - "from-entity": "From entity", - "delete": "Delete relation", - "relation-type": "Relation type", - "relation-type-required": "Relation type is required.", - "any-relation-type": "Any type", - "add": "Add relation", - "edit": "Edit relation", - "delete-to-relation-title": "Are you sure you want to delete relation to the entity '{{entityName}}'?", - "delete-to-relation-text": "Be careful, after the confirmation the entity '{{entityName}}' will be unrelated from the current entity.", - "delete-to-relations-title": "Are you sure you want to delete { count, plural, 1 {1 relation} other {# relations} }?", - "delete-to-relations-text": "Be careful, after the confirmation all selected relations will be removed and corresponding entities will be unrelated from the current entity.", - "delete-from-relation-title": "Are you sure you want to delete relation from the entity '{{entityName}}'?", - "delete-from-relation-text": "Be careful, after the confirmation current entity will be unrelated from the entity '{{entityName}}'.", - "delete-from-relations-title": "Are you sure you want to delete { count, plural, 1 {1 relation} other {# relations} }?", - "delete-from-relations-text": "Be careful, after the confirmation all selected relations will be removed and current entity will be unrelated from the corresponding entities.", - "remove-relation-filter": "Remove relation filter", - "add-relation-filter": "Add relation filter", - "any-relation": "Any relation", - "relation-filters": "Relation filters", - "additional-info": "Additional info (JSON)", - "invalid-additional-info": "Unable to parse additional info json." + "from-relations": "Relaciones salientes", + "to-relations": "Relaciones entrantes", + "selected-relations": "{ count, plural, 1 {1 relation} other {# relations} } selecciondas", + "type": "Tipo", + "to-entity-type": "Hacia tipo de entidad", + "to-entity-name": "Hacia nombre de entidad", + "from-entity-type": "Desde tipo de entidad", + "from-entity-name": "Desde nombre de entidad", + "to-entity": "Hacia entidad", + "from-entity": "Desde entidad", + "delete": "Eliminar relación", + "relation-type": "Tipo de relación", + "relation-type-required": "Tipo de relación es requerido.", + "any-relation-type": "Algún tipo", + "add": "Agregar relación", + "edit": "Editar relación", + "delete-to-relation-title": "¿Está seguro de que desea eliminar la relación hacia la entidad '{{entityName}}'?", + "delete-to-relation-text": "Tener cuidado, después de la confirmación, la entidad '{{entityName}}' no estará relacionada desde la entidad actual.", + "delete-to-relations-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 relation} other {# relations} }?", + "delete-to-relations-text": "Tener cuidado, después de la confirmación, se eliminarán todas las relaciones seleccionadas y las entidades correspondientes no estarán relacionadas desde la entidad actual.", + "delete-from-relation-title": "¿Está seguro de que desea eliminar la relación desde la entidad '{{entityName}}'?", + "delete-from-relation-text": "Tener cuidado, después de la confirmación, la entidad actual no será relacionada desde la entidad '{{entityName}}'.", + "delete-from-relations-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 relation} other {# relations} }?", + "delete-from-relations-text": "Tener cuidado, después de la confirmación, se eliminarán todas las relaciones seleccionadas y la entidad actual no será relacionada desde las correspondientes entidades.", + "remove-relation-filter": "Eliminar filtro de relación", + "add-relation-filter": "Agregar filtro de relación", + "any-relation": "Alguna relación", + "relation-filters": "Filtros de relación", + "additional-info": "Información adicional (JSON)", + "invalid-additional-info": "No se puede analizar información adicional json." }, "rulechain": { - "rulechain": "Rule chain", - "rulechains": "Rule chains", - "root": "Root", - "delete": "Delete rule chain", - "name": "Name", - "name-required": "Name is required.", - "description": "Description", - "add": "Add Rule Chain", - "set-root": "Make rule chain root", - "set-root-rulechain-title": "Are you sure you want to make the rule chain '{{ruleChainName}}' root?", - "set-root-rulechain-text": "After the confirmation the rule chain will become root and will handle all incoming transport messages.", - "delete-rulechain-title": "Are you sure you want to delete the rule chain '{{ruleChainName}}'?", - "delete-rulechain-text": "Be careful, after the confirmation the rule chain and all related data will become unrecoverable.", - "delete-rulechains-title": "Are you sure you want to delete { count, plural, 1 {1 rule chain} other {# rule chains} }?", - "delete-rulechains-action-title": "Delete { count, plural, 1 {1 rule chain} other {# rule chains} }", - "delete-rulechains-text": "Be careful, after the confirmation all selected rule chains will be removed and all related data will become unrecoverable.", - "add-rulechain-text": "Add new rule chain", - "no-rulechains-text": "No rule chains found", - "rulechain-details": "Rule chain details", - "details": "Details", - "events": "Events", - "system": "System", - "import": "Import rule chain", - "export": "Export rule chain", - "export-failed-error": "Unable to export rule chain: {{error}}", - "create-new-rulechain": "Create new rule chain", - "rulechain-file": "Rule chain file", - "invalid-rulechain-file-error": "Unable to import rule chain: Invalid rule chain data structure.", - "copyId": "Copy rule chain Id", - "idCopiedMessage": "Rule chain Id has been copied to clipboard", - "select-rulechain": "Select rule chain", - "no-rulechains-matching": "No rule chains matching '{{entity}}' were found.", - "rulechain-required": "Rule chain is required", - "management": "Rules management", - "debug-mode": "Debug mode" + "rulechain": "Cadena de reglas", + "rulechains": "Cadenas de reglas", + "root": "Raíz", + "delete": "Eliminar cadena de reglas", + "name": "Nombre", + "name-required": "El nombre es requerido.", + "description": "Descripción", + "add": "Agregar cadena de reglas", + "set-root": "Hacer la cadena de reglas raíz", + "set-root-rulechain-title": "¿Está seguro de que desea hacer la cadena de reglas '{{ruleChainName}}' root?", + "set-root-rulechain-text": "Después de la confirmación, la cadena de reglas se volverá raíz y manejará todos los mensajes de transporte entrantes.", + "delete-rulechain-title": "¿Está seguro de que desea eliminar la cadena de reglas '{{ruleChainName}}'?", + "delete-rulechain-text": "Tener cuidado, después de la confirmación, la cadena de reglas y todos los datos relacionados se volverán irrecuperables.", + "delete-rulechains-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 rule chain} other {# rule chains} }?", + "delete-rulechains-action-title": "Eliminar { count, plural, 1 {1 rule chain} other {# rule chains} }", + "delete-rulechains-text": "Tener cuidado, después de la confirmación se eliminarán todas las cadenas de reglas seleccionadas y todos los datos relacionados se volverán irrecuperables.", + "add-rulechain-text": "Agregar nueva cadena de reglas", + "no-rulechains-text": "Cadenas de reglas no encontradas", + "rulechain-details": "Detalles de la cadena de reglas", + "details": "Detalles", + "events": "Eventos", + "system": "Sistema", + "import": "Importar cadena de reglas", + "export": "Exportar cadena de reglas", + "export-failed-error": "No se puede exportar la cadena de reglas: {{error}}", + "create-new-rulechain": "Crear nueva cadena de reglas", + "rulechain-file": "Archivo de la cadena de reglas", + "invalid-rulechain-file-error": "No se puede importar la cadena de reglas: Estructura de datos de la cadena de reglas inválida.", + "copyId": "Copiar ID de la cadena de reglas", + "idCopiedMessage": "ID de la cadena de reglas ha sido copiada al portapapeles", + "select-rulechain": "Seleccionar cadena de reglas", + "no-rulechains-matching": "Cadenas de reglas que coincidan con '{{entity}}' no fueron encontradas.", + "rulechain-required": "Cadena de reglas es requerida", + "management": "Gestión de reglas", + "debug-mode": "Mode de depuración" }, "rulenode": { - "details": "Details", - "events": "Events", - "search": "Search nodes", - "open-node-library": "Open node library", - "add": "Add rule node", - "name": "Name", - "name-required": "Name is required.", - "type": "Type", - "description": "Description", - "delete": "Delete rule node", - "select-all-objects": "Select all nodes and connections", - "deselect-all-objects": "Deselect all nodes and connections", - "delete-selected-objects": "Delete selected nodes and connections", - "delete-selected": "Delete selected", - "select-all": "Select all", - "copy-selected": "Copy selected", - "deselect-all": "Deselect all", - "rulenode-details": "Rule node details", - "debug-mode": "Debug mode", - "configuration": "Configuration", - "link": "Link", - "link-details": "Rule node link details", - "add-link": "Add link", - "link-label": "Link label", - "link-label-required": "Link label is required.", - "custom-link-label": "Custom link label", - "custom-link-label-required": "Custom link label is required.", - "type-filter": "Filter", - "type-filter-details": "Filter incoming messages with configured conditions", - "type-enrichment": "Enrichment", - "type-enrichment-details": "Add additional information into Message Metadata", - "type-transformation": "Transformation", - "type-transformation-details": "Change Message payload and Metadata", - "type-action": "Action", - "type-action-details": "Perform special action", - "type-external": "External", - "type-external-details": "Interacts with external system", - "type-rule-chain": "Rule Chain", - "type-rule-chain-details": "Forwards incoming messages to specified Rule Chain", - "type-input": "Input", - "type-input-details": "Logical input of Rule Chain, forwards incoming messages to next related Rule Node", - "directive-is-not-loaded": "Defined configuration directive '{{directiveName}}' is not available.", - "ui-resources-load-error": "Failed to load configuration ui resources.", - "invalid-target-rulechain": "Unable to resolve target rule chain!", - "test-script-function": "Test script function", - "message": "Message", - "message-type": "Message type", - "message-type-required": "Message type is required", - "metadata": "Metadata", - "metadata-required": "Metadata entries can't be empty.", - "output": "Output", - "test": "Test", - "help": "Help" + "details": "Detalles", + "events": "Eventos", + "search": "Nodos de búsqueda", + "open-node-library": "Abrir libreria de nodos", + "add": "Agregar nodo de reglas", + "name": "Nombre", + "name-required": "El nombre es requerido.", + "type": "Tipo", + "description": "Descripción", + "delete": "Eliminar nodo de reglas", + "select-all-objects": "Seleccionar todos los nodos y conexiones", + "deselect-all-objects": "Deshacer selección de todos los nodos y conexiones", + "delete-selected-objects": "Eliminar nodos y conexiones seleccionados", + "delete-selected": "Eliminar seleccionado", + "select-all": "Seleccionar todos", + "copy-selected": "Copiar seleccionado", + "deselect-all": "Deshace selección de todos", + "rulenode-details": "Detalles del nodo de reglas", + "debug-mode": "Modo de depuración", + "configuration": "Configuración", + "link": "Enlace", + "link-details": "Detalles del enlace del nodo de reglas", + "add-link": "Agregar enlace", + "link-label": "Etiqueta del enlace", + "link-label-required": "Etiqueta del enlace es requerida.", + "custom-link-label": "Etiqueta del enlace personalizada", + "custom-link-label-required": "Etiqueta del enlace personalizado es requerida.", + "link-labels": "Etiquetas del enlace", + "link-labels-required": "Etiquetas del enlace son requeridas.", + "no-link-labels-found": "Etiquetas de enlaces no encontradas", + "no-link-label-matching": "'{{label}}' no encontrada.", + "create-new-link-label": "Crear una nueva!", + "type-filter": "Filtro", + "type-filter-details": "Filtrar mensajes entrantes con las condiciones configuradas", + "type-enrichment": "Enriquecimiento", + "type-enrichment-details": "Agregar información adicional en mensajes de metadatos", + "type-transformation": "Transformación", + "type-transformation-details": "Cambiar carga útil del Mensaje y Metadatos", + "type-action": "Acción", + "type-action-details": "Ejecutar acción especial", + "type-external": "Externo", + "type-external-details": "Interactuar con sistemas externos", + "type-rule-chain": "Cadena de reglas", + "type-rule-chain-details": "Reenvíar los mensajes entrantes a la cadena de reglas especificada", + "type-input": "Entrada", + "type-input-details": "Entrada lógica de la Cadena de Reglas, reenvíar los mensajes entrantes al siguiente nodo de regla relacionado.", + "type-unknown": "Desconocido", + "type-unknown-details": "Regla de nodo no resuelta", + "directive-is-not-loaded": "La directiva de configuración definida '{{directiveName}}' no está disponible.", + "ui-resources-load-error": "Error al cargar los recursos de configuración ui.", + "invalid-target-rulechain": "No se puede resolver la cadena de reglas objetivo!", + "test-script-function": "Probar función script", + "message": "Mensaje", + "message-type": "Tipo de mensaje", + "select-message-type": "Seleccionar tipo de mensaje", + "message-type-required": "Tipo de mensaje es requerido", + "metadata": "Metadatos", + "metadata-required": "La entradas de matadatos no pueden estar vacías.", + "output": "Salida", + "test": "Prueba", + "help": "Ayuda" }, "tenant": { - "tenants": "Tenants", - "management": "Gestión de Tenant", - "add": "Agregar Tenant", - "admins": "Admins", - "manage-tenant-admins": "Gestionar administradores tenant", - "delete": "Eliminar tenant", - "add-tenant-text": "Agregar nuevo tenant", - "no-tenants-text": "Ningún tenant encontrado", - "tenant-details": "Detalles del Tenant", - "delete-tenant-title": "¿Estás seguro que quieres eliminar el tenant '{{tenantTitle}}'?", - "delete-tenant-text": "Ten cuidado, luego de confirmar el tenant será eliminado y la información relacionada será irrecuperable.", - "delete-tenants-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 tenant} other {# tenants} }?", + "tenant": "Organización", + "tenants": "Organizaciones", + "management": "Gestión de la organización", + "add": "Agregar Organización", + "admins": "Administradores", + "manage-tenant-admins": "Gestionar administradores de la organización", + "delete": "Eliminar organización", + "add-tenant-text": "Agregar nueva organización", + "no-tenants-text": "Organizaciones no encontradas", + "tenant-details": "Detalles de la organización", + "delete-tenant-title": "¿Está seguro de que desea eliminar la organización '{{tenantTitle}}'?", + "delete-tenant-text": "Tener cuidado, después de la confirmación, la organización y todos los datos relacionados se volverán irrecuperables.", + "delete-tenants-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 tenant} other {# tenants} }?", "delete-tenants-action-title": "Eliminar { count, plural, 1 {1 tenant} other {# tenants} }", - "delete-tenants-text": "Ten cuidado, luego de confirmar los tenants seleccionados serán eliminados y la información relacionada será irrecuperable.", + "delete-tenants-text": "Tener cuidado, después de la confirmación se eliminarán todas las organizaciones seleccionadas y todos los datos relacionados se volverán irrecuperables.", "title": "Título", - "title-required": "Título requerido.", - "description": "Descripción" + "title-required": "Título es requerido.", + "description": "Descripción", + "details": "Detalles", + "events": "Eventos", + "copyId": "Copiar ID de la organización", + "idCopiedMessage": "ID de la organización ha sido copiado al portapapeles", + "select-tenant": "Seleccionar organización", + "no-tenants-matching": "Organizaciones que coincidan con '{{entity}}' no fueron encontradas.", + "tenant-required": "Organización es requerida" }, "timeinterval": { - "seconds-interval": "{ seconds, plural, 1 {1 segundo} other {# segundos} }", - "minutes-interval": "{ minutes, plural, 1 {1 minuto} other {# minutos} }", - "hours-interval": "{ hours, plural, 1 {1 hora} other {# horas} }", - "days-interval": "{ days, plural, 1 {1 día} other {# días} }", + "seconds-interval": "{ seconds, plural, 1 {1 second} other {# seconds} }", + "minutes-interval": "{ minutes, plural, 1 {1 minute} other {# minutes} }", + "hours-interval": "{ hours, plural, 1 {1 hour} other {# hours} }", + "days-interval": "{ days, plural, 1 {1 day} other {# days} }", "days": "Días", "hours": "Horas", "minutes": "Minutos", @@ -1110,210 +1309,250 @@ "advanced": "Avanzado" }, "timewindow": { - "days": "{ days, plural, 1 { día } other {# días } }", - "hours": "{ hours, plural, 0 { horas } 1 {1 hora } other {# horas } }", - "minutes": "{ minutes, plural, 0 { minutos } 1 {1 minuto } other {# minutos } }", - "seconds": "{ seconds, plural, 0 { segundos } 1 {1 segundo } other {# segundos } }", - "realtime": "Tiempo-real", - "history": "Histórico", - "last-prefix": "último", - "period": "desde {{ startTime }} hasta {{ endTime }}", - "edit": "Editar ventana de tiempo", - "date-range": "Rango de fechas", - "last": "Últimos", + "days": "{ days, plural, 1 { day } other {# days } }", + "hours": "{ hours, plural, 0 { hour } 1 {1 hour } other {# hours } }", + "minutes": "{ minutes, plural, 0 { minute } 1 {1 minute } other {# minutes } }", + "seconds": "{ seconds, plural, 0 { second } 1 {1 second } other {# seconds } }", + "realtime": "Tiempo real", + "history": "Historia", + "last-prefix": "última", + "period": "desde {{ startTime }} to {{ endTime }}", + "edit": "Editat ventana de tiempo", + "date-range": "Rango de fecha", + "last": "Última", "time-period": "Período de tiempo" }, "user": { + "user": "Usuario", "users": "Usuarios", - "customer-users": "Usuarios del Cliente", - "tenant-admins": "Tenant Admins", - "sys-admin": "Administrador del Sistema", - "tenant-admin": "Administrador Tenant", + "customer-users": "Usuarios cliente", + "tenant-admins": "Administradores de la Organización", + "sys-admin": "Administrador del sistema", + "tenant-admin": "Administrador de la organización", "customer": "Cliente", "anonymous": "Anónimo", - "add": "Agregar usuario", + "add": "Agregar Usuario", "delete": "Eliminar usuario", "add-user-text": "Agregar nuevo usuario", - "no-users-text": "Ningún usuario encontrado", - "user-details": "Detalles del usuario", - "delete-user-title": "¿Estás seguro que quieres eliminar el usuario '{{userEmail}}'?", - "delete-user-text": "Ten cuidado, luego de confirmar el usuario seleccionado será eliminado y la información relacionada será irrecuperable.", - "delete-users-title": "¿Estás seguro que quieres eliminar { count, plural, 1 {1 usuario} other {# usuarios} }?", - "delete-users-action-title": "Borrar { count, plural, 1 {1 usuario} other {# usuarios} }", - "delete-users-text": "Ten cuidado, luego de confirmar los usuarios seleccionados serán eliminados y la información relacionada será irrecuperable.", - "activation-email-sent-message": "Mail de activación enviado con éxito!", + "no-users-text": "Usuarios no encontrados", + "user-details": "Detalles de usuario", + "delete-user-title": "¿Está seguro de que desea eliminar el usuario '{{userEmail}}'?", + "delete-user-text": "Tener cuidado, después de la confirmación, el usuario y todos los datos relacionados se volverán irrecuperables.", + "delete-users-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 user} other {# users} }?", + "delete-users-action-title": "Delete { count, plural, 1 {1 user} other {# users} }", + "delete-users-text": "Tener cuidado, después de la confirmación se eliminarán todas los usuarios seleccionados y todos los datos relacionados se volverán irrecuperables.", + "activation-email-sent-message": "Correo electrónico de activación fue enviado exitosamente!", "resend-activation": "Reenviar activación", - "email": "Email", - "email-required": "Email requerido.", + "email": "Correo electrónico", + "email-required": "Correo electrónico es requerido.", + "invalid-email-format": "Formato de correo electrónico inválido.", "first-name": "Nombre", "last-name": "Apellido", "description": "Descripción", - "default-dashboard": "Panel por defecto", - "always-fullscreen": "Siempre en pantalla completa" + "default-dashboard": "Panel predeterminado", + "always-fullscreen": "Siempre pantalla completa", + "select-user": "Seleccionar usuario", + "no-users-matching": "Usuarios que coincidan con '{{entity}}' no fueron encontrados.", + "user-required": "Usuario es requerido", + "activation-method": "Método de activación", + "display-activation-link": "Mostrar enlace de activación", + "send-activation-mail": "Enviar correo electrónico de activación", + "activation-link": "Enlace de activación de usuario", + "activation-link-text": "Para activar el usuario, utilizar los siguientes activation link :", + "copy-activation-link": "Copiar enlace de activación", + "activation-link-copied-message": "El enlace de activación de usuario ha sido copiado al portapapeles", + "details": "Detalles", + "login-as-tenant-admin": "Iniciar sesión como Administrador de la Organización", + "login-as-customer-user": "Iniciar sesión como Usuario Cliente" }, "value": { "type": "Tipo de valor", - "string": "Cadena de texto", - "string-value": "Valor de cadena de texto", - "integer": "Nro entero", - "integer-value": "Valor de nro entero", - "invalid-integer-value": "Valor inválido", - "double": "Nro decimal", - "double-value": "Valor nro decimal", + "string": "Cadena de caracteres", + "string-value": "Valor de la cadena de caracteres", + "integer": "Entero", + "integer-value": "Valor entero", + "invalid-integer-value": "Valor de entero inválido", + "double": "Doble", + "double-value": "Valor doble", "boolean": "Booleano", "boolean-value": "Valor booleano", "false": "Falso", - "true": "Verdadero" + "true": "Verdadero", + "long": "Largo" }, "widget": { - "widget-library": "Bibloteca de Widgets", - "widget-bundle": "Paquetes de Widgets", + "widget-library": "Libreria de Widgets", + "widget-bundle": "Paquete de Widgets", "select-widgets-bundle": "Seleccionar paquete de widgets", - "management": "Gestión de Widgets", - "editor": "Editor de widgets", - "widget-type-not-found": "Problema al cargar la configuración del widget.
Probablemente asociado\n El tipo de widget fue eliminado.", - "widget-type-load-error": "Widget no pudo ser cargado debido a estos errores:", + "management": "Gestión de widget", + "editor": "Editor de Widget", + "widget-type-not-found": "Problema cargando configuración de widget.
Probablemente tipo de widget asociado fue eliminado.", + "widget-type-load-error": "El widget no fue cargado debido a los siguientes errores:", "remove": "Eliminar widget", "edit": "Editar widget", - "remove-widget-title": "¿Estás seguro que quieres eliminar el widget '{{widgetTitle}}'?", - "remove-widget-text": "Luego de confirmar el widget será eliminado y toda la información relacionada será irrecuperable..", - "timeseries": "Series de tiempo", + "remove-widget-title": "¿Está seguro de que desea eliminar el widget '{{widgetTitle}}'?", + "remove-widget-text": "Después de la confirmación, el widget y todos los datos relacionados se volverán irrecuperables.", + "timeseries": "Series temporales", + "search-data": "Buscar datos", + "no-data-found": "Datos no encontrados", "latest-values": "Últimos valores", "rpc": "Widget de control", + "alarm": "Widget de alarma", "static": "Widget estático", "select-widget-type": "Seleccionar tipo de widget", - "missing-widget-title-error": "El titulo del widget debe ser especificado!", + "missing-widget-title-error": "Título del widget debe ser especificado!", "widget-saved": "Widget guardado", - "unable-to-save-widget-error": "Imposible guardar widget! Tiene errores!", + "unable-to-save-widget-error": "No se puede guardar widget! El widget tiene errores!", "save": "Guardar widget", "saveAs": "Guardar widget como", "save-widget-type-as": "Guardar tipo de widget como", - "save-widget-type-as-text": "Por favor, ingrese un nuevo titulo y/o seleccione un paquete de destino.", - "toggle-fullscreen": "Cambiar a pantalla completa", - "run": "Correr widget", - "title": "Titulo", - "title-required": "Titulo requerido.", - "type": "Tipo", + "save-widget-type-as-text": "Por favor ingresar nuevo título del widget y/o seleccionar paquete de widgets objetivo", + "toggle-fullscreen": "Alternar pantalla completa", + "run": "Ejecutar widget", + "title": "Título del widget", + "title-required": "Título del widget es requerido.", + "type": "Tipo de widget", "resources": "Recursos", "resource-url": "JavaScript/CSS URL", "remove-resource": "Eliminar recurso", - "add-resource": "Agregar recurso", + "add-resource": "Agregar recursose", "html": "HTML", - "tidy": "Tidy", + "tidy": "Ordenado", "css": "CSS", "settings-schema": "Esquema de configuración", "datakey-settings-schema": "Esquema de configuración de clave de datos", "javascript": "Javascript", - "remove-widget-type-title": "¿Estás seguro que quieres eliminar el tipo del widget '{{widgetName}}'?", - "remove-widget-type-text": "Luego de confirmar el tipo será eliminado y la información relacionada será irrecuperable.", - "remove-widget-type": "Eliminar tipo de widget.", + "remove-widget-type-title": "¿Está seguro de que desea eliminar el tipo de widget '{{widgetName}}'?", + "remove-widget-type-text": "Después de la confirmación, el tipo de widget y todos los datos relacionados se volverán irrecuperables.", + "remove-widget-type": "Eliminar tipo de widget", "add-widget-type": "Agregar nuevo tipo de widget", - "widget-type-load-failed-error": "Error al cargar el tipo de widget!", - "widget-template-load-failed-error": "Error al cargar el template del widget!", + "widget-type-load-failed-error": "Failed to load widget type!", + "widget-template-load-failed-error": "Error al cargar la plantilla del widget!", "add": "Agregar Widget", - "undo": "Deshacer cambios", + "undo": "Deshacer cambios en el widget", "export": "Exportar widget" }, "widget-action": { - "header-button": "Widget header button", - "open-dashboard-state": "Navigate to new dashboard state", - "update-dashboard-state": "Update current dashboard state", - "open-dashboard": "Navigate to other dashboard", - "custom": "Custom action", - "target-dashboard-state": "Target dashboard state", - "target-dashboard-state-required": "Target dashboard state is required", - "set-entity-from-widget": "Set entity from widget", - "target-dashboard": "Target dashboard", - "open-right-layout": "Open right dashboard layout (mobile view)" + "header-button": "Botón del encabezado del widget", + "open-dashboard-state": "Navegar a nuevo estado del panel", + "update-dashboard-state": "Actualizar estado vigente del panel", + "open-dashboard": "Navegar a otro panel", + "custom": "Acción personalizada", + "target-dashboard-state": "Estado del panel objetivo", + "target-dashboard-state-required": "Estado del panel objetivo es requerido", + "set-entity-from-widget": "Asignar entidad desde widget", + "target-dashboard": "Panel objetivo", + "open-right-layout": "Abrir diseño del panel derecho (vista móvil)" }, "widgets-bundle": { "current": "Paquete actual", - "widgets-bundles": "Paquete de Widgets", - "add": "Agregar paquete de widgets", - "delete": "Eliminar paquete de widgets", + "widgets-bundles": "Paquetes de Widgets", + "add": "Agregar Paquete de Widgets", + "delete": "Eliminar paquete de widget", "title": "Título", - "title-required": "Título requerido.", + "title-required": "Título es requerido.", "add-widgets-bundle-text": "Agregar nuevo paquete de widgets", - "no-widgets-bundles-text": "Ningún paquete de widgets encontrado", - "empty": "Paquete de widgets vacío.", + "no-widgets-bundles-text": "Paquetes de widgets no encontrados", + "empty": "Paquete de widgets está vacío", "details": "Detalles", - "widgets-bundle-details": "Detalles del paquete de Widgets", - "delete-widgets-bundle-title": "¿Estás seguro que desea eliminar el paquete de widgets '{{widgetsBundleTitle}}'?", - "delete-widgets-bundle-text": "Ten cuidado, luego de confirmar todos los paquetes seleccionados serán eliminados y su información relacionada será irrecuperable.", - "delete-widgets-bundles-title": "¿Estás seguro que deseas eliminar { count, plural, 1 {1 paquete de widgets} other {# paquetes de widgets} }?", - "delete-widgets-bundles-action-title": "Eliminar { count, plural, 1 {1 paquete de widgets} other {# paquetes de widgets} }", - "delete-widgets-bundles-text": "Ten cuidado, luego de confirmar todos los paquetes seleccionados serán eliminados y la información relacionada será irrecuperable.", - "no-widgets-bundles-matching": "Ningún paquete '{{widgetsBundle}}' encontrado.", - "widgets-bundle-required": "Paquete de widget requerido.", + "widgets-bundle-details": "Detalles del paquete de widgets", + "delete-widgets-bundle-title": "¿Está seguro de que desea eliminar el paquete de widgets '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Tener cuidado, después de la confirmación, el paquete de widgets y todos los datos relacionados se volverán irrecuperables.", + "delete-widgets-bundles-title": "¿Está seguro de que desea eliminar { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }?", + "delete-widgets-bundles-action-title": "Eliminar { count, plural, 1 {1 widgets bundle} other {# widgets bundles} }", + "delete-widgets-bundles-text": "Tener cuidado, después de la confirmación se eliminarán todas los paquetes de widgets seleccionados y todos los datos relacionados se volverán irrecuperables.", + "no-widgets-bundles-matching": "Paquetes de widgets que coincidan con '{{widgetsBundle}}' no fueron encontrados.", + "widgets-bundle-required": "Paquete de widgets es requerido.", "system": "Sistema", "import": "Importar paquete de widgets", "export": "Exportar paquete de widgets", - "export-failed-error": "Imposible exportar paquete de widgets: {{error}}", + "export-failed-error": "No se puede exportar paquete de widgets: {{error}}", "create-new-widgets-bundle": "Crear nuevo paquete de widgets", "widgets-bundle-file": "Archivo de paquete de widgets", - "invalid-widgets-bundle-file-error": "Imposible importar paquete de widgets: Estructura de datos inválida." + "invalid-widgets-bundle-file-error": "No se puede importar paquete de widgets: Estructura de datos del paquete de widgets inválida." }, "widget-config": { "data": "Datos", - "settings": "Ajustes", + "settings": "Configuración", "advanced": "Avanzado", - "title": "Titulo", - "general-settings": "Ajustes generales", - "display-title": "Mostrar titulo", - "drop-shadow": "Sombra", + "title": "Título", + "general-settings": "Configuaración general", + "display-title": "Mostrar título", + "drop-shadow": "Colocar sombra", "enable-fullscreen": "Habilitar pantalla completa", "background-color": "Color de fondo", "text-color": "Color del texto", "padding": "Relleno", - "title-style": "Estilo de título", - "mobile-mode-settings": "Ajustes mobile.", + "margin": "Margen", + "widget-style": "Estilo del widget", + "title-style": "Estilo del título", + "mobile-mode-settings": "Configuración del modo móvil", "order": "Orden", "height": "Altura", - "units": "Caracter especial a mostrar en el siguiente valor", - "decimals": "Números de dígitos después de la coma", + "units": "Símbolo especial para mostrar junto al valor.", + "decimals": "Número de dígitos después del punto flotante", "timewindow": "Ventana de tiempo", - "use-dashboard-timewindow": "Usar ventana de tiempo del Panel", + "use-dashboard-timewindow": "Utilizar ventana de timpo del panel", "display-legend": "Mostrar leyenda", - "datasources": "Set de datos", + "datasources": "Fuentes de datos", + "maximum-datasources": "Máximo { count, plural, 1 {1 datasource is allowed.} other {# datasources are allowed} }", "datasource-type": "Tipo", - "datasource-parameters": "Parámetros", - "remove-datasource": "Eliminar set de datos", - "add-datasource": "Agregar set de datos", - "target-device": "Dispositivo destino" + "datasource-parameters": "Parametros", + "remove-datasource": "Eliminar fuente de datos", + "add-datasource": "Agregar fuente de datos", + "target-device": "Dispositivo objetivo", + "alarm-source": "Fuente de alarma", + "actions": "Accines", + "action": "Acción", + "add-action": "Agregar acción", + "search-actions": "Buscar acciones", + "action-source": "Fuente de acción", + "action-source-required": "Fuente de acción es requerida.", + "action-name": "Nombre", + "action-name-required": "Nombre de acción es requerido.", + "action-name-not-unique": "Ya existe otra acción con el mismo nombre.
El nombre de la acción debe ser único dentro de la misma fuente de acción.", + "action-icon": "Icono", + "action-type": "Tipo", + "action-type-required": "Tipo de acción es requerido.", + "edit-action": "Editar acción", + "delete-action": "Eliminar acción", + "delete-action-title": "Eliminar acción del widget", + "delete-action-text": "¿Está seguro de que desea eliminar la acción del widget con nombre '{{actionName}}'?" }, "widget-type": { "import": "Importar tipo de widget", "export": "Exportar tipo de widget", - "export-failed-error": "Imposible exportar tipo de widget: {{error}}", + "export-failed-error": "No se puede exportar tipo de widget: {{error}}", "create-new-widget-type": "Crear nuevo tipo de widget", - "widget-type-file": "Tipo de archivo del widget", - "invalid-widget-type-file-error": "Imposible de importar tipo de widget: Estructura de datos inválida." + "widget-type-file": "Archivo de tipo de widget", + "invalid-widget-type-file-error": "No se puede importar tipo de widget: Estructura de datos del tipo de widget es inválida." }, "icon": { - "icon": "Icon", - "select-icon": "Select icon", - "material-icons": "Material icons", - "show-all": "Show all icons" + "icon": "Icono", + "select-icon": "Seleccionar icono", + "material-icons": "Iconos de maerial", + "show-all": "Mostrar todos los iconos" }, "custom": { "widget-action": { - "action-cell-button": "Action cell button", - "row-click": "On row click", - "marker-click": "On marker click", - "tooltip-tag-action": "Tooltip tag action" + "action-cell-button": "Botón de acción de celda", + "row-click": "Clic en la fila", + "marker-click": "Clic en el marcador", + "tooltip-tag-action": "Acción de etiqueta para globo de ayuda" } }, "language": { "language": "Lenguaje", "locales": { + "fr_FR": "Francés", + "zh_CN": "Chino", "en_US": "Inglés", - "fr_FR": "Francés", + "it_IT": "Italiano", "ko_KR": "Coreano", - "zh_CN": "Chino", "ru_RU": "Ruso", "es_ES": "Español", - "it_IT": "Italiano", - "ja_JA": "Japonés" + "ja_JA": "Japonés", + "tr_TR": "Turco" } } } diff --git a/ui/src/app/locale/locale.constant-fr_FR.json b/ui/src/app/locale/locale.constant-fr_FR.json index 2c0a290e16..dd2fa0d864 100644 --- a/ui/src/app/locale/locale.constant-fr_FR.json +++ b/ui/src/app/locale/locale.constant-fr_FR.json @@ -1,1461 +1,1462 @@ { -"access":{ - "access-forbidden": "Accès interdit", - "access-forbidden-text": "Vous n'avez pas accès à cet emplacement!
Essayez de vous connecter avec un autre utilisateur si vous souhaitez toujours accéder à cet emplacement.", - "refresh-token-expired": "La session a expiré", - "refresh-token-failed": "Impossible de rafraîchir la session", - "unauthorized": "non autorisé", - "unauthorized-access": "accès non autorisé", - "unauthorized-access-text": "Vous devez vous connecter pour avoir accès à cette ressource!" - }, -"action":{ - "activate": "Activer", - "add": "Ajouter", - "apply": "Appliquer", - "apply-changes": "Appliquer les modifications", - "assign": "Attribuer", - "back": "retour", - "cancel": "Annuler", - "clear-search": "Effacer la recherche", - "close": "Fermer", - "copy": "Copier", - "copy-reference": "Copier la référence", - "create": "Créer", - "decline-changes": "Refuser les modifications", - "delete": "Supprimer", - "drag": "Drag", - "edit": "Modifier", - "edit-mode": "Mode édition", - "enter-edit-mode": "Entrer en mode édition", - "export": "Exporter", - "import": "Importer", - "make-private": "Rendre privé", - "no": "Non", - "ok": "OK", - "paste": "coller", - "paste-reference": "Coller référence", - "refresh": "Rafraîchir", - "remove": "Supprimer", - "run": "Exécuter", - "save": "Enregistrer", - "saveAs": "Enregistrer sous", - "search": "Rechercher", - "share": "Partager", - "share-via": "Partager via {{provider}}", - "sign-in": "Connectez-vous!", - "suspend": "Suspendre", - "unassign": "Retirer", - "undo": "Annuler", - "update": "mise à jour", - "view": "Afficher", - "yes": "Oui" - }, -"admin":{ - "base-url": "URL de base", - "base-url-required": "L'URL de base est requise.", - "enable-tls": "Activer TLS", - "general": "Général", - "general-settings": "Paramètres généraux", - "mail-from": "Mail de", - "mail-from-required": "Mail de est requis.", - "outgoing-mail": "courrier sortant", - "outgoing-mail-settings": "Paramètres de courrier sortant", - "send-test-mail": "Envoyer un mail de test", - "smtp-host": "Hôte SMTP", - "smtp-host-required": "L'hôte SMTP est requis.", - "smtp-port": "Port SMTP", - "smtp-port-invalid": "Cela ne ressemble pas à un port smtp valide.", - "smtp-port-required": "Vous devez fournir un port smtp.", - "smtp-protocol": "Protocole SMTP", - "system-settings": "Paramètres système", - "test-mail-sent": "Le courrier de test a été envoyé avec succès!", - "timeout-invalid": "Cela ne ressemble pas à un délai d'expiration valide.", - "timeout-msec": "Délai (msec)", - "timeout-required": "Le délai est requis." - }, -"aggregation":{ - "aggregation": "agrégation", - "avg": "Moyenne", - "count": "Compte", - "function": "Fonction d'agrégation de données", - "group-interval": "Intervalle de regroupement", - "limit": "Valeurs maximales", - "max": "Max", - "min": "Min", - "none": "Aucune", - "sum": "Somme" - }, -"alarm":{ - "ack-time": "Heure d'acquittement", - "acknowledge": "Acquitter", - "aknowledge-alarms-text": "Etes-vous sûr de vouloir acquitter {count, plural, 1 {1 alarme} other {# alarmes}}?", - "aknowledge-alarms-title": "Acquitter {count, plural, 1 {1 alarme} other {# alarmes}}", - "alarm": "Alarme", - "alarm-details": "Détails de l'alarme", - "alarm-required": "Une alarme est requise", - "alarm-status": "Etat d'alarme", - "alarms": "Alarmes", - "clear": "Effacer", - "clear-alarms-text": "Êtes-vous sûr de vouloir effacer {count, plural, 1 {1 alarme} other {# alarmes}}?", - "clear-alarms-title": "Effacer {count, plural, 1 {1 alarme} other {# alarmes}}", - "clear-time": "Heure d'éffacement", - "created-time": "Heure de création", - "details": "Détails", - "display-status":{ - "ACTIVE_ACK": "Active acquittée", - "ACTIVE_UNACK": "Active non acquittée", - "CLEARED_ACK": "effacée acquittée", - "CLEARED_UNACK": "effacée non acquittée" - }, - "end-time": "Heure de fin", - "min-polling-interval-message": "Un intervalle d'interrogation d'au moins 1 seconde est autorisé.", - "no-alarms-matching": "Aucune alarme correspondant à {{entity}} n'a été trouvée. ", - "no-alarms-prompt": "Aucune alarme trouvée", - "no-data": "Aucune donnée à afficher", - "originator": "Source", - "originator-type": "Type de Source", - "polling-interval": "Intervalle d'interrogation des alarmes (sec)", - "polling-interval-required": "L'intervalle d'interrogation des alarmes est requis.", - "search": "Rechercher des alarmes", - "search-status":{ - "ACK": "acquitté", - "ACTIVE": "active", - "ANY": "Toutes", - "CLEARED": "effacée", - "UNACK": "non acquittée" - }, - "select-alarm": "Sélectionnez une alarme", - "selected-alarms": "{count, plural, 1 {1 alarme} other {# alarmes}} sélectionnées", - "severity": "Gravitée", - "severity-critical": "Critique", - "severity-indeterminate": "indéterminée", - "severity-major": "Majeure", - "severity-minor": "mineure", - "severity-warning": "Avertissement", - "start-time": "Heure de début", - "status": "Etat", - "type": "Type" - }, -"alias":{ - "add": "Ajouter un alias", - "all-entities": "Toutes les entités", - "any-relation": "toutes", - "default-entity-parameter-name": "Par défaut", - "default-state-entity": "Entité d'état par défaut", - "duplicate-alias": "Un alias portant le même nom existe déjà.", - "edit": "Modifier l'alias", - "entity-filter": "Filtre d'entité", - "entity-filter-no-entity-matched": "Aucune entité correspondant au filtre spécifié n'a été trouvée.", - "filter-type": "Type de filtre", - "filter-type-asset-search-query": "requête de recherche d'Assets", - "filter-type-asset-search-query-description": "Assets de types {{assetTypes}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-asset-type": "type d'Asset", - "filter-type-asset-type-and-name-description": "Assets de type '{{assetType}}' et dont le nom commence par '{{prefix}}'", - "filter-type-asset-type-description": "Assets de type '{{assetType}}'", - "filter-type-device-search-query": "Requête de recherche de dispositif", - "filter-type-device-search-query-description": "Dispositifs de types {{deviceTypes}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-device-type": "Type de dispositif", - "filter-type-device-type-and-name-description": "Dispositifs de type '{{deviceType}}' et dont le nom commence par '{{prefix}}'", - "filter-type-device-type-description": "Dispositifs de type '{{deviceType}}'", - "filter-type-entity-list": "Liste d'entités", - "filter-type-entity-name": "Nom d'entité", - "filter-type-relations-query": "Interrogation des relations", - "filter-type-relations-query-description": "{{entities}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-required": "Le type de filtre est requis.", - "filter-type-single-entity": "Entité unique", - "filter-type-state-entity": "Entité de l'état du tableau de bord", - "filter-type-state-entity-description": "Entité extraite des paramètres d'état du tableau de bord", - "max-relation-level": "Niveau de relation maximum", - "name": "Nom de l'alias", - "name-required": "Le nom d'alias est requis", - "no-entity-filter-specified": "Aucun filtre d'entité spécifié", - "resolve-multiple": "Résoudre en plusieurs entités", - "root-entity": "Entité racine", - "root-state-entity": "Utiliser l'entité d'état du tableau de bord en tant que racine", - "state-entity": "Entité d'état du tableau de bord", - "state-entity-parameter-name": "Nom du paramètre d'entité d'état", - "unlimited-level": "niveau illimité" - }, -"asset":{ - "add": "Ajouter un Asset", - "add-asset-text": "Ajouter un nouvel Asset", - "any-asset": "Tout Asset", - "asset": "Asset", - "asset-details": "Détails de l'Asset", - "asset-public": "L'Asset est public", - "asset-required": "Asset requis", - "asset-type": "Type d'Asset", - "asset-type-list-empty": "Aucun type d'Asset sélectionné.", - "asset-type-required": "Le type d'Asset est requis.", - "asset-types": "Types d'Asset", - "assets": "Assets", - "assign-asset-to-customer": "Attribuer des Assets au client", - "assign-asset-to-customer-text": "Veuillez sélectionner les Assets à attribuer au client", - "assign-assets": "Attribuer des Assets", - "assign-assets-text": "Attribuer {count, plural, 1 {1 asset} other {# assets}} au client", - "assign-new-asset": "Attribuer un nouvel Asset", - "assign-to-customer": "Attribuer au client", - "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les Assets", - "assignedToCustomer": "attribué au client", - "copyId": "Copier l'Id de l'Asset", - "delete": "Supprimer un Asset", - "delete-asset-text": "Faites attention, après la confirmation, l'Asset et toutes les données associées deviendront irrécupérables.", - "delete-asset-title": "Êtes-vous sûr de vouloir supprimer l'Asset '{{assetName}}'?", - "delete-assets": "Supprimer des Assets", - "delete-assets-action-title": "Supprimer {count, plural, 1 {1 asset} other {# assets}}", - "delete-assets-text": "Attention, après la confirmation, tous les Assets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-assets-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 asset} other {# assets}}?", - "description": "Description", - "details": "Détails", - "enter-asset-type": "Entrez le type d'Asset", - "events": "Evènements", - "idCopiedMessage": "L'Id d'asset a été copié dans le presse-papier", - "make-private": "Rendre l'Asset privé", - "make-private-asset-text": "Après la confirmation, l'Asset et toutes ses données seront rendus privés et ne seront pas accessibles par d'autres.", - "make-private-asset-title": "Etes-vous sûr de vouloir rendre l'Asset '{{assetName}}' privé '?", - "make-public": "Rendre l'Asset public", - "make-public-asset-text": "Après la confirmation, l'asset et toutes ses données seront rendus publics et accessibles aux autres.", - "make-public-asset-title": "Êtes-vous sûr de vouloir rendre l'Asset '{{assetName}}' public '?", - "management": "Gestion d'Assets", - "name": "Nom", - "name-required": "Nom est requis.", - "name-starts-with": "Le nom de l'Asset commence par", - "no-asset-types-matching": "Aucun type d'Asset correspondant à {{entitySubtype}} n'a été trouvé. ", - "no-assets-matching": "Aucun Asset correspondant à {{entity}} n'a été trouvé. ", - "no-assets-text": "Aucun Asset trouvé", - "public": "Public", - "select-asset": "Sélectionner un Asset", - "select-asset-type": "Sélectionner le type d'Asset", - "type": "Type", - "type-required": "Le type est requis.", - "unassign-asset": "Retirer l'Asset", - "unassign-asset-text": "Après la confirmation, l'Asset sera non attribué et ne sera pas accessible au client.", - "unassign-asset-title": "Êtes-vous sûr de vouloir retirer l'attribution de l'Asset '{{assetName}}'?", - "unassign-assets": "Retirer les Assets", - "unassign-assets-action-title": "Retirer {count, plural, 1 {1 asset} other {# assets}} du client", - "unassign-assets-text": "Après la confirmation, tous les Assets sélectionnés ne seront pas attribués et ne seront pas accessibles au client.", - "unassign-assets-title": "Êtes-vous sûr de vouloir retirer l'attribution de {count, plural, 1 {1 asset} other {# assets}}?", - "unassign-from-customer": "Retirer du client", - "view-assets": "Afficher les Assets" - }, -"attribute":{ - "add": "Ajouter un attribut", - "add-to-dashboard": "Ajouter au tableau de bord", - "add-widget-to-dashboard": "Ajouter un widget au tableau de bord", - "attributes": "Attributs", - "attributes-scope": "Etendue des attributs d'entité", - "delete-attributes": "Supprimer les attributs", - "delete-attributes-text": "Attention, après la confirmation, tous les attributs sélectionnés seront supprimés.", - "delete-attributes-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 attribut} other {# attributs}}?", - "enter-attribute-value": "Entrez la valeur de l'attribut", - "key": "Clé", - "key-required": "La Clé d'attribut est requise.", - "last-update-time": "Dernière mise à jour", - "latest-telemetry": "Dernière télémétrie", - "next-widget": "Widget suivant", - "prev-widget": "Widget précédent", - "scope-client": "Attributs du client", - "scope-latest-telemetry": "Dernière télémétrie", - "scope-server": "Attributs du serveur", - "scope-shared": "Attributs partagés", - "selected-attributes": "{count, plural, 1 {1 attribut} other {# attributs}} sélectionnés", - "selected-telemetry": "{count, plural, 1 {1 unité de télémétrie} other {# unités de télémétrie}} sélectionnées", - "show-on-widget": "Afficher sur le widget", - "value": "Valeur", - "value-required": "La valeur d'attribut est obligatoire.", - "widget-mode": "Mode du widget" - }, -"audit-log":{ - "action-data": "Action data", - "audit": "Audit", - "audit-log-details": "Détails du journal d'audit", - "audit-logs": "Journaux d'audit", - "clear-search": "Effacer la recherche", - "details": "Détails", - "entity-name": "Nom de l'entité", - "entity-type": "Type d'entité", - "failure-details": "Détails de l'échec", - "no-audit-logs-prompt": "Aucun journal trouvé", - "search": "Rechercher les journaux d'audit", - "status": "Etat", - "status-failure": "Échec", - "status-success": "Succès", - "timestamp": "Horodatage", - "type": "Type", - "type-activated": "Activé", - "type-added": "Ajouté", - "type-alarm-ack": "Acquitté", - "type-alarm-clear": "Effacé", - "type-assigned-to-customer": "Attribué au client", - "type-attributes-deleted": "Attributs supprimés", - "type-attributes-read": "Attributs lus", - "type-attributes-updated": "Attributs mis à jour", - "type-credentials-read": "Lecture des informations d'identification", - "type-credentials-updated": "Informations d'identification actualisées", - "type-deleted": "Supprimé", - "type-relation-add-or-update": "Relation mise à jour", - "type-relation-delete": "Relation supprimée", - "type-relations-delete": "Toutes les relations ont été supprimées", - "type-rpc-call": "Appel RPC", - "type-suspended": "Suspendu", - "type-unassigned-from-customer": "Non attribué du client", - "type-updated": "Mise à jour", - "user": "Utilisateur" - }, -"common":{ - "enter-password": "Entrez le mot de passe", - "enter-search": "Entrez la recherche", - "enter-username": "Entrez le nom d'utilisateur", - "password": "Mot de passe", - "username": "Nom d'utilisateur" - }, -"confirm-on-exit":{ - "html-message": "Vous avez des modifications non enregistrées.
Êtes-vous sûr de vouloir quitter cette page?", - "message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page?", - "title": "Modifications non enregistrées" - }, -"contact":{ - "address": "Adresse", - "address2": "adresse 2", - "city": "Ville", - "country": "Pays", - "email": "Email", - "no-address": "Pas d'adresse", - "phone": "Téléphone", - "postal-code": "Code postal", - "postal-code-invalid": "Format de code postal / code postal invalide", - "state": "Etat / Province" - }, -"content-type":{ - "binary": "Binaire (Base64)", - "json": "Json", - "text": "Texte" - }, -"custom":{ - "widget-action":{ - "action-cell-button": "Action cell button", - "marker-click": "On marker click", - "row-click": "On row click", - "tooltip-tag-action": "Tooltip tag action" - } - }, -"customer":{ - "add": "Ajouter un client", - "add-customer-text": "Ajouter un nouveau client", - "assets": "Assets du client", - "copyId": "Copier l'id du client", - "customer": "Client", - "customer-details": "Détails du client", - "customer-required": "Le client est requis", - "customers": "Clients", - "dashboard": "Tableau de bord du client", - "dashboards": "tableaux de bord du client", - "default-customer": "Client par défaut", - "default-customer-required": "Le client par défaut est requis pour déboguer le tableau de bord au niveau du Tenant", - "delete": "Supprimer le client", - "delete-customer-text": "Faites attention, après la confirmation, le client et toutes les données associées deviendront irrécupérables.", - "delete-customer-title": "Êtes-vous sûr de vouloir supprimer le client '{{customerTitle}}'?", - "delete-customers-action-title": "Supprimer {count, plural, 1 {1 client} other {# clients}}", - "delete-customers-text": "Faites attention, après la confirmation, tous les clients sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-customers-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 client} other {# clients}}?", - "description": "Description", - "details": "Détails", - "devices": "Dispositifs du client", - "events": "Événements", - "idCopiedMessage": "L'Id du client a été copié dans le presse-papier", - "manage-assets": "Gérer les Assets", - "manage-customer-assets": "Gérer les Assets du client", - "manage-customer-dashboards": "Gérer les tableaux de bord du client", - "manage-customer-devices": "Gérer les dispositifs du client", - "manage-customer-users": "Gérer les utilisateurs du client", - "manage-dashboards": "Gérer les tableaux de bord", - "manage-devices": "Gérer les dispositifs", - "manage-public-assets": "Gérer les Assets publics", - "manage-public-dashboards": "Gérer les tableaux de bord publics", - "manage-public-devices": "Gérer les dispositifs publics", - "manage-users": "Gérer les utilisateurs", - "management": "Gestion des clients", - "no-customers-matching": "Aucun client correspondant à '{{entity}} n'a été trouvé.", - "no-customers-text": "Aucun client trouvé", - "public-assets": "Assets publics", - "public-dashboards": "Tableaux de bord publics", - "public-devices": "Dispositifs publics", - "select-customer": "Sélectionner un client", - "select-default-customer": "Sélectionnez le client par défaut", - "title": "Titre", - "title-required": "Le titre est requis." - }, -"dashboard":{ - "add": "Ajouter un tableau de bord", - "add-dashboard-text": "Ajouter un nouveau tableau de bord", - "add-state": "Ajouter un état du tableau de bord", - "add-widget": "Ajouter un nouveau widget", - "alias-resolution-error-title": "Erreur de configuration des alias de tableau de bord", - "assign-dashboard-to-customer": "Attribuer des tableaux de bord au client", - "assign-dashboard-to-customer-text": "Veuillez sélectionner les tableaux de bord à affecter au client", - "assign-dashboards": "Attribuer des tableaux de bord", - "assign-dashboards-text": "Attribuer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} aux clients", - "assign-new-dashboard": "Attribuer un nouveau tableau de bord", - "assign-to-customer": "Attribuer au client", - "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les tableaux de bord", - "assign-to-customers": "Attribuer des tableaux de bord aux clients", - "assign-to-customers-text": "Veuillez sélectionner les clients pour attribuer les tableaux de bord", - "assigned-customers": "clients affectés", - "assignedToCustomer": "Attribué au client", - "assignedToCustomers": "attribué aux clients", - "autofill-height": "Hauteur de remplissage automatique", - "background-color": "Couleur de fond", - "background-image": "Image d'arrière-plan", - "background-size-mode": "Mode de taille d'arrière-plan", - "close-toolbar": "Fermer la barre d'outils", - "columns-count": "Nombre de colonnes", - "columns-count-required": "Le nombre de colonnes est requis.", - "configuration-error": "Erreur de configuration", - "copy-public-link": "Copier le lien public", - "create-new": "Créer un nouveau tableau de bord", - "create-new-dashboard": "Créer un nouveau tableau de bord", - "create-new-widget": "Créer un nouveau widget", - "dashboard": "Tableau de bord", - "dashboard-details": "Détails du tableau de bord", - "dashboard-file": "Fichier du tableau de bord", - "dashboard-import-missing-aliases-title": "Configurer les alias utilisés par le tableau de bord importé", - "dashboard-required": "Le tableau de bord est requis.", - "dashboards": "Tableaux de bord", - "delete": "Supprimer le tableau de bord", - "delete-dashboard-text": "Faites attention, après la confirmation, le tableau de bord et toutes les données associées deviendront irrécupérables.", - "delete-dashboard-title": "Êtes-vous sûr de vouloir supprimer le tableau de bord '{{dashboardTitle}}'?", - "delete-dashboards": "Supprimer les tableaux de bord", - "delete-dashboards-action-title": "Supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}", - "delete-dashboards-text": "Attention, après la confirmation, tous les tableaux de bord sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-dashboards-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}?", - "delete-state": "Supprimer l'état du tableau de bord", - "delete-state-text": "Etes-vous sûr de vouloir supprimer l'état du tableau de bord avec le nom '{{stateName}}'?", - "delete-state-title": "Supprimer l'état du tableau de bord", - "description": "Description", - "details": "Détails", - "display-dashboard-export": "Afficher l'exportation", - "display-dashboard-timewindow": "Afficher fenêtre de temps", - "display-dashboards-selection": "Afficher la sélection des tableaux de bord", - "display-entities-selection": "Afficher la sélection des entités", - "display-title": "Afficher le titre du tableau de bord", - "drop-image": "Déposer une image ou cliquez pour sélectionner un fichier à télécharger.", - "edit-state": "Modifier l'état du tableau de bord", - "export": "Exporter le tableau de bord", - "export-failed-error": "Impossible d'exporter le tableau de bord: {{error}}", - "hide-details": "Masquer les détails", - "horizontal-margin": "Marge horizontale", - "horizontal-margin-required": "Une valeur de marge horizontale est requise.", - "import": "Importer le tableau de bord", - "import-widget": "Importer un widget", - "invalid-aliases-config": "Impossible de trouver des dispositifs correspondant à certains filtres d'alias.
Veuillez contacter votre administrateur pour résoudre ce problème.", - "invalid-dashboard-file-error": "Impossible d'importer le tableau de bord: structure de données du tableau de bord non valide", - "invalid-widget-file-error": "Impossible d'importer le widget: structure de données de widget invalide.", - "is-root-state": "Etat racine", - "make-private": "Rendre privé le tableau de bord", - "make-private-dashboard": "Rendre privé le tableau de bord", - "make-private-dashboard-text": "Après la confirmation, le tableau de bord sera rendu privé et ne sera plus accessible aux autres.", - "make-private-dashboard-title": "Etes-vous sûr de vouloir rendre le tableau de bord '{{dashboardTitle}}' privé?", - "make-public": "Rendre public le tableau de bord", - "manage-assigned-customers": "Gérer les clients affectés", - "manage-states": "Gérer les états du tableau de bord", - "management": "Gestion du tableau de bord", - "max-columns-count-message": "Seulement 1000 colonnes maximum sont autorisées.", - "max-horizontal-margin-message": "Seulement 50 sont autorisés en tant que valeur de marge horizontale maximale.", - "max-mobile-row-height-message": "Seuls 200 pixels sont autorisés en tant que valeur maximale de hauteur de ligne mobile.", - "max-vertical-margin-message": "Seulement 50 sont autorisés en tant que valeur de marge verticale maximale.", - "min-columns-count-message": "Seul un nombre minimum de 10 colonnes est autorisé.", - "min-horizontal-margin-message": "Seul 0 est autorisé comme valeur de marge horizontale minimale.", - "min-mobile-row-height-message": "Seuls 5 pixels sont autorisés en tant que valeur minimale de hauteur de ligne mobile.", - "min-vertical-margin-message": "Seul 0 est autorisé comme valeur de marge verticale minimale.", - "mobile-layout": "Paramètres de mise en page mobiles", - "mobile-row-height": "Hauteur de ligne mobile, px", - "mobile-row-height-required": "Une valeur de hauteur de ligne mobile est requise.", - "new-dashboard-title": "Nouveau titre du tableau de bord", - "no-dashboards-matching": "Aucun tableau de bord correspondant à {{entity}} n'a été trouvé. ", - "no-dashboards-text": "Aucun tableau de bord trouvé", - "no-image": "Aucune image sélectionnée", - "no-widgets": "Aucun widget configuré", - "open-dashboard": "Ouvrir le tableau de bord", - "open-toolbar": "Ouvrir la barre d'outils du tableau de bord", - "public": "Public", - "public-dashboard-notice": " Remarque: N'oubliez pas de rendre publics les dispositifs associés pour accéder à leurs données.", - "public-dashboard-text": "Votre tableau de bord {{dashboardTitle}} est maintenant public et accessible via le lien public : ", - "public-dashboard-title": "Le tableau de bord est maintenant public", - "public-link": "Lien public", - "public-link-copied-message": "Le lien public du tableau de bord a été copié dans le presse-papier", - "search-states": "Recherche des états du tableau de bord", - "select-dashboard": "Sélectionner le tableau de bord", - "select-devices": "Selectionner les dispositifs", - "select-existing": "Sélectionnez un tableau de bord existant", - "select-state": "Sélectionnez l'état cible", - "select-widget-subtitle": "Liste des types de widgets disponibles", - "select-widget-title": "Sélectionner un widget", - "selected-states": "{count, plural, 1 {1 état du tableau de bord} other {# états du tableau de bord}} sélectionnés", - "set-background": "Définir l'arrière-plan", - "settings": "Paramètres", - "show-details": "Afficher les détails", - "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", - "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", - "state": "Etat du tableau de bord", - "state-controller": "Contrôleur d'état", - "state-id": "ID d'état", - "state-id-exists": "L'état du tableau de bord avec le même Id existe déjà.", - "state-id-required": "L'Id d'état du tableau de bord est requis.", - "state-name": "Nom", - "state-name-required": "Le nom de l'état du tableau de bord est requis", - "states": "Etats du tableau de bord", - "title": "Titre", - "title-color": "Couleur du titre", - "title-required": "Le titre est requis.", - "toolbar-always-open": "Garder la barre d'outils ouverte", - "unassign-dashboard": "Retirer le tableau de bord", - "unassign-dashboard-text": "Après la confirmation, le tableau de bord ne sera pas attribué et ne sera pas accessible au client.", - "unassign-dashboard-title": "Êtes-vous sûr de vouloir annuler l'affectation du tableau de bord '{{dashboardTitle}}'?", - "unassign-dashboards": "Retirer les tableaux de bord", - "unassign-dashboards-action-text": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} des clients", - "unassign-dashboards-action-title": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} du client", - "unassign-dashboards-text": "Après la confirmation, tous les tableaux de bord sélectionnés ne seront pas attribués et ne seront pas accessibles au client.", - "unassign-dashboards-title": "Etes-vous sûr de vouloir annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}?", - "unassign-from-customer": "Retirer du client", - "unassign-from-customers": "Retirer les tableaux de bord des clients", - "unassign-from-customers-text": "Veuillez sélectionner les clients à annuler l'affectation du ou des tableaux de bord", - "vertical-margin": "Marge verticale", - "vertical-margin-required": "Une valeur de marge verticale est requise", - "view-dashboards": "Afficher les tableaux de bord", - "widget-file": "Fichier du Widget", - "widget-import-missing-aliases-title": "Configurer les alias utilisés par le widget importé", - "widgets-margins": "Marge entre les widgets" - }, -"datakey":{ - "advanced": "Avancé", - "alarm": "Champs d'alarme", - "alarm-fields-required": "Les champs d'alarme sont obligatoires.", - "attributes": "Attributs", - "color": "Couleur", - "configuration": "Configuration de la clé de données", - "data-generation-func": "Fonction de génération de données", - "decimals": "Nombre de chiffres après virgule flottante", - "function-types": "Types de fonctions", - "function-types-required": "Les types de fonctions sont obligatoires", - "label": "Label", - "maximum-function-types": "Maximum {count, plural, 1 {1 type de fonction est autorisé.} other {# types de fonctions sont autorisés}}", - "maximum-timeseries-or-attributes": "Maximum {count, plural, 1 {1 timeseries / attribut est autorisé.} other {# timeseries / attributs sont autorisés}}", - "settings": "Paramètres", - "timeseries": "Timeseries", - "timeseries-or-attributes-required": "Les timeseries / attributs d'entité sont obligatoires.", - "timeseries-required": "Les Timeseries de l'entité sont obligatoires.", - "units": "Symbole spécial à afficher à côté de la valeur", - "use-data-post-processing-func": "Utiliser la fonction de post-traitement des données" - }, -"datasource":{ - "add-datasource-prompt": "Veuillez ajouter une source de données", - "name": "Nom", - "type": "Type de source de données" - }, -"datetime":{ - "date-from": "Date de", - "date-to": "Date à", - "time-from": "Heure de", - "time-to": "Heure à" - }, -"details":{ - "edit-mode": "Mode édition", - "toggle-edit-mode": "Activer le mode édition" - }, -"device":{ - "access-token": "Jeton d'accès", - "access-token-invalid": "La longueur du jeton d'accès doit être comprise entre 1 et 20 caractères.", - "access-token-required": "Le jeton d'accès est requis.", - "accessTokenCopiedMessage": "Le jeton d'accès au dispositif a été copié dans le presse-papier", - "add": "Ajouter un dispositif", - "add-alias": "Ajouter un alias de dispositif", - "add-device-text": "Ajouter un nouveau dispositif", - "alias": "Alias", - "alias-required": "Un alias du dispositif est requis.", - "aliases": "Alias ​​du dispositif", - "any-device": "N'importe quel dispositif", - "assign-device-to-customer": "Affecter des dispositifs au client", - "assign-device-to-customer-text": "Veuillez sélectionner les dispositif à affecter au client", - "assign-devices": "Attribuer des dispositifs", - "assign-devices-text": "Attribuer {count, plural, 1 {1 dispositif} other {# dispositifs}} au client", - "assign-new-device": "Attribuer un nouveau dispositif", - "assign-to-customer": "Attribuer au client", - "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les dispositifs", - "assignedToCustomer": "Attribué au client", - "configure-alias": "Configurer '{{alias}}' alias", - "copyAccessToken": "Copier le jeton d'accès", - "copyId": "Copier l'Id du dispositif", - "create-new-alias": "Créez un nouveau!", - "create-new-key": "Créez un nouveau!", - "credentials": "Informations d'identification", - "credentials-type": "Type d'identification", - "delete": "Supprimer le dispositif", - "delete-device-text": "Faites attention, après la confirmation, le dispositif et toutes les données associées deviendront irrécupérables.", - "delete-device-title": "Êtes-vous sûr de vouloir supprimer le dispositif '{{deviceName}}'?", - "delete-devices": "Supprimer les dispositifs", - "delete-devices-action-title": "Supprimer {count, plural, 1 {1 dispositif} other {# dispositifs}}", - "delete-devices-text": "Faites attention, après la confirmation, tous les dispositifs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-devices-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 dispositif} other {# dispositifs}}?", - "description": "Description", - "details": "Détails", - "device": "Dispositif", - "device-alias": "Alias ​​du dispositif", - "device-credentials": "Informations d'identification du dispositif", - "device-details": "Détails du dispositif", - "device-list": "Liste des dispositifs", - "device-list-empty": "Aucun dispositif sélectionné.", - "device-name-filter-no-device-matched": "Aucun dispositif commençant par '{{device}} n'a été trouvé.", - "device-name-filter-required": "Le filtre de nom de dispositif est requis.", - "device-public": "Le dispositif est public", - "device-required": "Le dispositif est requis.", - "device-type": "Type de dispositif", - "device-type-list-empty": "Aucun type de dispositif sélectionné.", - "device-type-required": "Le type de dispositif est requis.", - "device-types": "Types de dispositif", - "devices": "Dispositifs", - "duplicate-alias-error": "Alias ​​en double trouvé '{{alias}}'.
Les alias de dispositifs doivent être uniques dans le tableau de bord.", - "enter-device-type": "Entrez le type de dispositif", - "events": "Événements", - "idCopiedMessage": "l'Id du dispositif a été copié dans le presse-papiers", - "is-gateway": "Est une passerelle", - "make-private": "Rendre le dispositif privé", - "make-private-device-text": "Après la confirmation, le dispositif et toutes ses données seront rendues privées et ne seront pas accessibles par d'autres.", - "make-private-device-title": "Etes-vous sûr de vouloir rendre le dispositif {{deviceName}} privé?", - "make-public": "Rendre le dispositif public", - "make-public-device-text": "Après la confirmation, le dispositif et toutes ses données seront rendus publics et accessibles par d'autres.", - "make-public-device-title": "Êtes-vous sûr de vouloir rendre le dispositif {{deviceName}} 'public?", - "manage-credentials": "Gérer les informations d'identification", - "management": "Gestion des dispositifs", - "name": "Nom", - "name-required": "Le nom est requis.", - "name-starts-with": "Le nom du dispositif commence par", - "no-alias-matching": "'{{alias}}' introuvable.", - "no-aliases-found": "Aucun alias trouvé.", - "no-device-types-matching": "Aucun type de dispositif correspondant à {{entitySubtype}} n'a été trouvé.", - "no-devices-matching": "Aucun dispositif correspondant à '{{entity}} n'a été trouvé.", - "no-devices-text": "Aucun dispositif trouvé", - "no-key-matching": "'{{key}}' introuvable.", - "no-keys-found": "Aucune clé trouvée", - "public": "Public", - "remove-alias": "Supprimer l'alias du dispositif", - "rsa-key": "Clé publique RSA", - "rsa-key-required": "La clé publique RSA est requise.", - "secret": "Secret", - "secret-required": "Code secret est requis.", - "select-device": "Selectionner un dispositif", - "select-device-type": "Sélectionner le type d'appareil", - "unable-delete-device-alias-text": "L'alias du dispositif '{{deviceAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
{{widgetsList}}", - "unable-delete-device-alias-title": "Impossible de supprimer l'alias du dispositif", - "unassign-device": "Annuler l'affectation du dispositif", - "unassign-device-text": "Après la confirmation, le dispositif ne sera pas attribué et ne sera pas accessible au client.", - "unassign-device-title": "Êtes-vous sûr de vouloir annuler l'affection du dispositif {{deviceName}} '?", - "unassign-devices": "Annuler l'affectation des dispositifs", - "unassign-devices-action-title": "Annuler l'affectation de {count, plural, 1 {1 dispositif} other {#dispositifs}} du client", - "unassign-devices-text": "Après la confirmation, tous les dispositifs sélectionnés ne seront pas attribues et ne seront pas accessibles par le client.", - "unassign-devices-title": "Voulez-vous vraiment annuler l'affectation de {count, plural, 1 {1 dispositif} other {# dispositifs}}?", - "unassign-from-customer": "Retirer du client", - "use-device-name-filter": "Utiliser le filtre", - "view-credentials": "Afficher les informations d'identification", - "view-devices": "Afficher les dispositifs" - }, -"dialog":{ - "close": "Fermer le dialogue" - }, -"entity" : { - "add-alias": "Ajouter un alias d'entité", - "alarm-name-starts-with": "Les alarmes dont le nom commence par '{{prefix}}'", - "alias": "Alias", - "alias-required": "Un alias d'entité est requis.", - "aliases": "alias d'entité", - "all-subtypes": "Tout", - "any-entity": "Toute entité", - "asset-name-starts-with": "Les Assets dont le nom commence par '{{prefix}}'", - "configure-alias": "Configurer '{{alias}}' alias", - "create-new-alias": "Créez un nouveau!", - "create-new-key": "Créez un nouveau!", - "customer-name-starts-with": "Les clients dont les noms commencent par '{{prefix}}'", - "dashboard-name-starts-with": "Les tableaux de bord dont les noms commencent par '{{prefix}}'", - "details": "Détails de l'entité", - "device-name-starts-with": "Dispositifs dont le nom commence par '{{prefix}}'", - "duplicate-alias-error": "Alias ​​en double trouvé '{{alias}}'.
Les alias d'entité doivent être uniques dans le tableau de bord.", - "enter-entity-type": "Entrez le type d'entité", - "entities": "Entités", - "entity": "Entité", - "entity-alias": "Alias de l'entité", - "entity-list": "Liste d'entités", - "entity-list-empty": "Aucune entité sélectionnée.", - "entity-name": "Nom de l'entité", - "entity-name-filter-no-entity-matched": "Aucune entité commençant par '{{entity}}' n'a été trouvée.", - "entity-name-filter-required": "Le filtre de nom d'entité est requis.", - "entity-type": "Type d'entité", - "entity-type-list": "Liste de types d'entités", - "entity-type-list-empty": "Aucun type d'entité sélectionné.", - "entity-types": "Types d'entité", - "key": "Clé", - "key-name": "Nom de la clé", - "list-of-alarms": "{count, plural, 1 {Une alarme} other {Liste de # alarmes}}", - "list-of-assets": "{count, plural, 1 {Un Asset} other {Liste de # Assets}}", - "list-of-customers": "{count, plural, 1 {Un client} other {Liste de # clients}}", - "list-of-dashboards": "{count, plural, 1 {Un tableau de bord} other {Liste de # tableaux de bord}}", - "list-of-devices": "{count, plural, 1 {Un dispositif} other {Liste de # dispositifs}}", - "list-of-plugins": "{count, plural, 1 {Un plugin} other {Liste de # plugins}}", - "list-of-rulechains": "{count, plural, 1 {Une chaîne de règles} other {Liste de # chaînes de règles}}", - "list-of-rulenodes": "{count, plural, 1 {Un noeud de règles} other {Liste de # noeuds de règles}}", - "list-of-rules": "{count, plural, 1 {Une règle} other {Liste de # règles}}", - "list-of-tenants": "{count, plural, 1 {Un tenant} other {Liste de # tenants}}", - "list-of-users": "{count, plural, 1 {Un utilisateur} other {Liste de # utilisateurs}}", - "missing-entity-filter-error": "Le filtre est manquant pour l'alias '{{alias}}'.", - "name-starts-with": "Nom commence par", - "no-alias-matching": "'{{alias}}' introuvable.", - "no-aliases-found": "Aucun alias trouvé.", - "no-data": "Aucune donnée à afficher", - "no-entities-matching": "Aucune entité correspondant à '{{entity}}' n'a été trouvée.", - "no-entities-prompt": "Aucune entité trouvée", - "no-entity-types-matching": "Aucun type d'entité correspondant à {{entityType}} n'a été trouvé. ", - "no-key-matching": "'{{key}}' introuvable.", - "no-keys-found": "Aucune clé trouvée", - "plugin-name-starts-with": "Plugins dont les noms commencent par '{{prefix}}'", - "remove-alias": "Supprimer l'alias d'entité", - "rule-name-starts-with": "Règles dont les noms commencent par '{{prefix}}'", - "rulechain-name-starts-with": "Chaînes de règles dont les noms commencent par '{{prefix}}'", - "rulenode-name-starts-with": "Les noeuds de règles dont le nom commence par '{{prefix}}'", - "search": "Recherche d'entités", - "select-entities": "Sélectionner des entités", - "selected-entities": "{count, plural, 1 {1 entité} other {# entités}} sélectionnées", - "tenant-name-starts-with": "Les Tenant dont le nom commence par '{{prefix}}'", - "type": "Type", - "type-alarm": "Alarme", - "type-alarms": "Alarmes", - "type-asset": "Asset", - "type-assets": "Assets", - "type-current-customer": "Client actuel", - "type-customer": "Client", - "type-customers": "Clients", - "type-dashboard": "Tableau de bord", - "type-dashboards": "Tableaux de bord", - "type-device": "Dispositif", - "type-devices": "Dispositifs", - "type-plugin": "Plugin", - "type-plugins": "Plugins", - "type-required": "Le type d'entité est obligatoire.", - "type-rule": "Règle", - "type-rulechain": "Chaîne de règles", - "type-rulechains": "Chaînes de règles", - "type-rulenode": "Noeud de règle", - "type-rulenodes": "Noeuds de règle", - "type-rules": "Règles", - "type-tenant": "Tenant", - "type-tenants": "Tenants", - "type-user": "Utilisateur", - "type-users": "Utilisateurs", - "unable-delete-entity-alias-text": "L'alias d'entité '{{entityAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
{{widgetsList}}", - "unable-delete-entity-alias-title": "Impossible de supprimer l'alias d'entité", - "use-entity-name-filter": "Utiliser un filtre", - "user-name-starts-with": "Utilisateurs dont les noms commencent par '{{prefix}}'" - }, -"error":{ - "unable-to-connect": "Impossible de se connecter au serveur! Veuillez vérifier votre connexion Internet.", - "unhandled-error-code": "Code d'erreur non géré: {{errorCode}}", - "unknown-error": "Erreur inconnue" - }, -"event":{ - "alarm": "Alarme", - "body": "Corps", - "data": "Données", - "data-type": "Type de données", - "entity": "Entité", - "error": "erreur", - "errors-occurred": "Des erreurs sont survenues", - "event": "événement", - "event-time": "Heure de l'événement", - "event-type": "Type d'événement", - "failed": "Échec", - "message-id": "Message Id", - "message-type": "Type de message", - "messages-processed": "Messages traités", - "metadata": "Métadonnées", - "method": "Méthode", - "no-events-prompt": "Aucun événement trouvé", - "relation-type": "Type de relation", - "server": "Serveur", - "status": "Etat", - "success": "Succès", - "type": "Type", - "type-debug-rule-chain": "Debug", - "type-debug-rule-node": "Debug", - "type-error": "Erreur", - "type-lc-event": "Evénement du cycle de vie", - "type-stats": "Statistiques" - }, -"extension":{ - "add": "Ajouter une extension", - "add-attribute": "Ajouter un attribut", - "add-attribute-request": "Ajouter une demande d'attribut", - "add-attribute-update": "Ajouter une mise à jour d'attribut", - "add-broker": "Ajouter un Broker", - "add-config": "Ajouter une configuration de convertisseur", - "add-connect-request": "Ajouter une demande de connexion", - "add-converter": "Ajouter un convertisseur", - "add-device": "Ajouter un dispositif", - "add-disconnect-request": "Ajouter une demande de déconnexion", - "add-map": "Ajouter un élément de mappage", - "add-server-side-rpc-request": "Ajouter une requête RPC côté serveur", - "add-timeseries": "Ajouter des timeseries", - "anonymous": "Anonyme", - "attr-json-key-expression": "Expression json de la clé d'attribut", - "attr-topic-key-expression": "Expression du topic de la clé d'attribut", - "attribute-filter": "Filtre d'attribut", - "attribute-key-expression": "Expression de clé d'attribut", - "attribute-requests": "Demandes d'attributs", - "attribute-updates": "Mises à jour des attributs", - "attributes": "Attributs", - "basic": "Basic", - "brokers": "Brokers", - "ca-cert": "Fichier de certificat CA", - "cert": "Fichier de certificat *", - "client-scope": "Portée client", - "configuration": "Configuration", - "connect-requests": "Demandes de connexion", - "converter-configurations": "Configurations du convertisseur", - "converter-id": "ID du convertisseur", - "converter-json": "Json", - "converter-json-parse": "Impossible d'analyser le convertisseur json.", - "converter-json-required": "Le convertisseur json est requis.", - "converter-type": "Type de convertisseur", - "converters": "Convertisseurs", - "credentials": "Informations d'identification", - "custom": "Custom", - "delete": "Supprimer l'extension", - "delete-extension-text": "Attention, après la confirmation, l'extension et toutes les données associées deviendront irrécupérables.", - "delete-extension-title": "Êtes-vous sûr de vouloir supprimer l'extension '{{extensionId}}'?", - "delete-extensions-text": "Attention, après la confirmation, toutes les extensions sélectionnées seront supprimées.", - "delete-extensions-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 extension} other {# extensions}}?", - "device-name-expression": "expression du nom du dispositif", - "device-name-filter": "Filtre de nom de dispositif", - "device-type-expression": "expression de type de dispositif", - "disconnect-requests": "Demandes de déconnection", - "drop-file": "Déposez un fichier ou cliquez pour sélectionner un fichier à télécharger.", - "edit": "Modifier l'extension", - "export-extension": "Exporter l'extension", - "export-extensions-configuration": "Exporter la configuration des extensions", - "extension-id": "Id de l'extension", - "extension-type": "Type d'extension", - "extensions": "Extensions", - "field-required": "Le champ est obligatoire", - "file": "Fichier d'extensions", - "filter-expression": "Expression du filtre", - "host": "Hôte", - "id": "Id", - "import-extension": "Importer une extension", - "import-extensions": "Importer des extensions", - "import-extensions-configuration": "Importer la configuration des extensions", - "invalid-file-error": "Fichier d'extension non valide", - "json-name-expression": "Expression json du nom du dispositif", - "json-parse": "Impossible d'analyser json transformer.", - "json-required": "Transformer json est requis.", - "json-type-expression": "Expression json du type de dispositif", - "key": "Clé", - "mapping": "Mappage", - "method-filter": "Filtre de méthode", - "modbus-add-server": "Ajouter serveur/esclave", - "modbus-add-server-prompt": "Veuillez ajouter serveur/esclave", - "modbus-attributes-poll-period": "Période d'interrogation des attributs (ms)", - "modbus-baudrate": "Débit en bauds", - "modbus-byte-order": "Ordre des octets", - "modbus-databits": "Bits de données", - "modbus-databits-range": "Les bits de données doivent être compris entre 7 et 8.", - "modbus-device-name": "Nom du dispositif", - "modbus-encoding": "Encodage", - "modbus-function": "Fonction", - "modbus-parity": "parité", - "modbus-poll-period": "Période d'interrogation (ms)", - "modbus-poll-period-range": "La période d'interrogation doit être une valeur positive.", - "modbus-port-name": "Nom du port série", - "modbus-register-address": "Adresse du registre", - "modbus-register-address-range": "L'adresse du registre doit être comprise entre 0 et 65535.", - "modbus-register-bit-index": "Bit index", - "modbus-register-bit-index-range": "L'index de bit doit être compris entre 0 et 15.", - "modbus-register-count": "Nombre de registre", - "modbus-register-count-range": "Le nombre de registres doit être une valeur positive.", - "modbus-server": "Serveurs / esclaves", - "modbus-stopbits": "Bits d'arrêt", - "modbus-stopbits-range": "Les bits d'arrêt doivent être compris entre 1 et 2.", - "modbus-tag": "Tag", - "modbus-timeseries-poll-period": "Période d'interrogation des Timeseries (ms)", - "modbus-transport": "Transport", - "modbus-unit-id": "Id de l'unité", - "modbus-unit-id-range": "L'ID de l'unité doit être compris entre 1 et 247.", - "no-file": "Aucun fichier sélectionné.", - "opc-add-server": "Ajouter un serveur", - "opc-add-server-prompt": "Veuillez ajouter un serveur", - "opc-application-name": "Nom de l'application", - "opc-application-uri": "Uri de l'application", - "opc-device-name-pattern": "modèle de nom du dispositif", - "opc-device-node-pattern": "modèle de noeud de dispositif", - "opc-identity": "Identité", - "opc-keystore": "Magasin de clés", - "opc-keystore-alias": "Alias", - "opc-keystore-key-password": "Mot de passe de la clé", - "opc-keystore-location": "Emplacement *", - "opc-keystore-password": "Mot de passe", - "opc-keystore-type": "Type", - "opc-scan-period-in-seconds": "Période d'analyse en secondes", - "opc-security": "Sécurité", - "opc-server": "Serveurs", - "opc-type": "Type", - "password": "Mot de passe", - "pem": "PEM", - "port": "Port", - "port-range": "Le port doit être compris entre 1 et 65535.", - "private-key": "Fichier de clé privée *", - "request-id-expression": "Expression de demande d'id", - "request-id-json-expression": "Expression json de la demande d'id", - "request-id-topic-expression": "Expression de la demande d'id du topic", - "request-topic-expression": "Expression de la demande du topic", - "response-timeout": "Délai de réponse en millisecondes", - "response-topic-expression": "Expression du topic de la réponse", - "retry-interval": "Intervalle de nouvelle tentative en millisecondes", - "selected-extensions": "{count, plural, 1 {1 extension} other {# extensions}} sélectionné", - "server-side-rpc": "RPC côté serveur", - "ssl": "Ssl", - "sync":{ - "last-sync-time": "Dernière heure de synchronisation", - "not-available": "Non disponible", - "not-sync": "Non sync", - "status": "Status", - "sync": "Sync" - }, - "timeout": "Délai d'attente en millisecondes", - "timeseries": "Timeseries", - "to-double": "To Double", - "token": "Jeton de sécurité", - "topic": "Topic", - "topic-expression": "Expression du topic", - "topic-filter": "Filtre du topic", - "topic-name-expression": "Expression du nom du dispositif (topic)", - "topic-type-expression": "Expression de type de dispositif (topic)", - "transformer": "Transformer", - "transformer-json": "JSON *", - "type": "Type", - "unique-id-required": "L'identifiant d'extension actuel existe déjà.", - "username": "Nom d'utilisateur", - "value": "Valeur", - "value-expression": "Expression de la valeur" - }, -"fullscreen":{ - "exit": "Quitter le plein écran", - "expand": "Afficher en plein écran", - "fullscreen": "Plein écran", - "toggle": "Activer le mode plein écran" - }, -"function":{ - "function": "Fonction" - }, -"grid":{ - "add-item-text": "Ajouter un nouvel élément", - "delete-item": "Supprimer l'élément", - "delete-item-text": "Faites attention, après la confirmation, cet élément et toutes les données associées deviendront irrécupérables.", - "delete-item-title": "Êtes-vous sûr de vouloir supprimer cet élément?", - "delete-items": "Supprimer les éléments", - "delete-items-action-title": "Supprimer {count, plural, 1 {1 élément} other {# éléments}}", - "delete-items-text": "Attention, après la confirmation, tous les éléments sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-items-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 élément} other {# éléments}}?", - "item-details": "Détails de l'élément", - "no-items-text": "Aucun élément trouvé", - "scroll-to-top": "Défiler vers le haut" - }, -"help":{ - "goto-help-page": "Aller à la page d'aide" - }, -"home":{ - "avatar": "Avatar", - "home": "Accueil", - "logout": "Déconnexion", - "menu": "Menu", - "open-user-menu": "Ouvrir le menu utilisateur", - "profile": "Profile" - }, -"icon":{ - "icon": "Icône", - "material-icons": "Material icons", - "select-icon": "Sélectionner l'icône", - "show-all": "Afficher toutes les icônes" - }, -"import":{ - "drop-file": "Déposez un fichier JSON ou cliquez pour sélectionner un fichier à télécharger.", - "no-file": "Aucun fichier sélectionné" - }, -"item":{ - "selected": "Sélectionné" - }, -"js-func":{ - "no-return-error": "La fonction doit renvoyer une valeur!", - "return-type-mismatch": "La fonction doit renvoyer une valeur de type '{{type}}' !", - "tidy": "Tidy" - }, -"key-val":{ - "add-entry": "Ajouter une entrée", - "key": "Clé", - "no-data": "Aucune entrée", - "remove-entry": "Supprimer l'entrée", - "value": "Valeur" - }, -"language":{ - "language": "Language", - "locales":{ - "en_US": "Anglais", - "fr_FR": "Français", - "es_ES": "Espagnol", - "it_IT": "Italien", - "ko_KR": "Coréen", - "ru_RU": "Russe", - "zh_CN": "Chinois" - } - }, -"layout":{ - "color": "Couleur", - "layout": "Mise en page", - "main": "Principal", - "manage": "Gérer les mises en page", - "right": "Droite", - "select": "Sélectionner la mise en page cible", - "settings": "Paramètres de mise en page" - }, -"legend":{ - "avg": "avg", - "max": "max", - "min": "min", - "position": "Position de la légende", - "settings": "Paramètres de la légende", - "show-avg": "Afficher la valeur moyenne", - "show-max": "Afficher la valeur maximale", - "show-min": "Afficher la valeur min", - "show-total": "Afficher la valeur totale", - "total": "total" - }, -"login":{ - "create-password": "Créer un mot de passe", - "email": "Email", - "forgot-password": "Mot de passe oublié?", - "login": "Login", - "new-password": "Nouveau mot de passe", - "new-password-again": "nouveau mot de passe", - "password-again": "Mot de passe à nouveau", - "password-link-sent-message": "Le lien de réinitialisation du mot de passe a été envoyé avec succès!", - "password-reset": "Mot de passe réinitialisé", - "passwords-mismatch-error": "Les mots de passe saisis doivent être identiques!", - "remember-me": "Se souvenir de moi", - "request-password-reset": "Demander la réinitialisation du mot de passe", - "reset-password": "Réinitialiser le mot de passe", - "sign-in": "Veuillez vous connecter", - "username": "Nom d'utilisateur (email)" - }, -"position":{ - "bottom": "Bas", - "left": "Gauche", - "right": "Droite", - "top": "Haut" - }, -"profile":{ - "change-password": "Modifier le mot de passe", - "current-password": "Mot de passe actuel", - "profile": "Profile" - }, -"relation":{ - "add": "Ajouter une relation", - "add-relation-filter": "Ajouter un filtre de relation", - "additional-info": "Informations supplémentaires (JSON)", - "any-relation": "toute relation", - "any-relation-type": "N'importe quel type", - "delete": "Supprimer la relation", - "delete-from-relation-text": "Attention, après la confirmation, l'entité actuelle ne sera pas liée à l'entité '{{entityName}}'.", - "delete-from-relation-title": "Etes-vous sûr de vouloir supprimer la relation de l'entité '{{entityName}}'?", - "delete-from-relations-text": "Attention, après la confirmation, toutes les relations sélectionnées seront supprimées et l'entité actuelle ne sera pas liée aux entités correspondantes.", - "delete-from-relations-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations}}?", - "delete-to-relation-text": "Attention, après la confirmation, l'entité '{{entityName}} ne sera plus liée à l'entité actuelle.", - "delete-to-relation-title": "Êtes-vous sûr de vouloir supprimer la relation avec l'entité '{{entityName}}'?", - "delete-to-relations-text": "Attention, après la confirmation, toutes les relations sélectionnées seront supprimées et les entités correspondantes ne seront pas liées à l'entité en cours.", - "delete-to-relations-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations}}?", - "direction": "Sens", - "direction-type":{ - "FROM": "de", - "TO": "à" - }, - "edit": "Modifier la relation", - "from-entity": "De l'entité", - "from-entity-name": "Du nom d'entité", - "from-entity-type": "Du type d'entité", - "from-relations": "Relations sortantes", - "invalid-additional-info": "Impossible d'analyser les informations supplémentaires json.", - "relation-filters": "Filtres de relation", - "relation-type": "Type de relation", - "relation-type-required": "Le type de relation est requis.", - "relations": "Relations", - "remove-relation-filter": "Supprimer le filtre de relation", - "search-direction":{ - "FROM": "De", - "TO": "À" - }, - "selected-relations": "{count, plural, 1 {1 relation} other {# relations}} sélectionné", - "to-entity": "À l'entité", - "to-entity-name": "vers le nom de l'entité", - "to-entity-type": "Vers le type d'entité", - "to-relations": "Relations entrantes", - "type": "Type" - }, -"rulechain":{ - "add": "Ajouter une chaîne de règles", - "add-rulechain-text": "Ajouter une nouvelle chaîne de règles", - "copyId": "Copier l'identifiant de la chaîne de règles", - "create-new-rulechain": "Créer une nouvelle chaîne de règles", - "debug-mode": "Mode de débogage", - "delete": "Supprimer la chaîne de règles", - "delete-rulechain-text": "Attention, après la confirmation, la chaîne de règles et toutes les données associées deviendront irrécupérables.", - "delete-rulechain-title": "Voulez-vous vraiment supprimer la chaîne de règles '{{ruleChainName}}'?", - "delete-rulechains-action-title": "Supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles}}", - "delete-rulechains-text": "Attention, après la confirmation, toutes les chaînes de règles sélectionnées seront supprimées et toutes les données associées deviendront irrécupérables.", - "delete-rulechains-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles}}?", - "description": "Description", - "details": "Détails", - "events": "Evénements", - "export": "Exporter la chaîne de règles", - "export-failed-error": "Impossible d'exporter la chaîne de règles: {{error}}", - "idCopiedMessage": "L'ID de la chaîne de règles a été copié dans le presse-papier", - "import": "Importer la chaîne de règles", - "invalid-rulechain-file-error": "Impossible d'importer la chaîne de règles: structure de données de la chaîne de règles non valide", - "management": "Gestion des règles", - "name": "Nom", - "name-required": "Le nom est requis.", - "no-rulechains-matching": "Aucune chaîne de règles correspondant à {{entity}} n'a été trouvée.", - "no-rulechains-text": "Aucune chaîne de règles trouvée", - "root": "Racine", - "rulechain": "Chaîne de règles", - "rulechain-details": "Détails de la chaîne de règles", - "rulechain-file": "Fichier de chaîne de règles", - "rulechain-required": "Chaîne de règles requise", - "rulechains": "Chaînes de règles", - "select-rulechain": "Sélectionner la chaîne de règles", - "set-root": "Rend la chaîne de règles racine (root) ", - "set-root-rulechain-text": "Après la confirmation, la chaîne de règles deviendra racine (root) et gérera tous les messages de transport entrants.", - "set-root-rulechain-title": "Voulez-vous vraiment que la chaîne de règles '{{ruleChainName}} soit racine (root) ?", - "system": "Système" - }, -"rulenode":{ - "add": "Ajouter un noeud de règle", - "add-link": "Ajouter un lien", - "configuration": "Configuration", - "copy-selected": "Copier les éléments sélectionnés", - "create-new-link-label": "Créez un nouveau!", - "custom-link-label": "Etiquette de lien personnalisée", - "custom-link-label-required": "Une étiquette de lien personnalisée est requise", - "debug-mode": "Mode de débogage", - "delete": "Supprimer le noeud de règle", - "delete-selected": "Supprimer les éléments sélectionnés", - "delete-selected-objects": "Supprimer les nœuds et les connexions sélectionnés", - "description": "Description", - "deselect-all": "Désélectionner tout", - "deselect-all-objects": "Désélectionnez tous les nœuds et toutes les connexions", - "details": "Détails", - "directive-is-not-loaded": "La directive de configuration définie '{{directiveName}} n'est pas disponible.", - "events": "Événements", - "help": "Aide", - "invalid-target-rulechain": "Impossible de résoudre la chaîne de règles cible!", - "link": "Lien", - "link-details": "Détails du lien du noeud de la règle", - "link-label": "Étiquette du lien", - "link-label-required": "L'étiquette du lien est obligatoire", - "link-labels": "Étiquettes de lien", - "link-labels-required": "Les étiquettes de lien sont obligatoires", - "message": "Message", - "message-type": "Type de message", - "message-type-required": "Le type de message est obligatoire", - "metadata": "Métadonnées", - "metadata-required": "Les entrées de métadonnées ne peuvent pas être vides.", - "name": "Nom", - "name-required": "Le nom est requis.", - "no-link-label-matching": "'{{label}}' introuvable.", - "no-link-labels-found": "Aucune étiquette de lien trouvée", - "open-node-library": "Ouvrir la bibliothèque de noeud", - "output": "Output", - "rulenode-details": "Détails du noeud de la règle", - "search": "Recherche de noeuds", - "select-all": "Tout sélectionner", - "select-all-objects": "Sélectionnez tous les noeuds et connexions", - "select-message-type": "Sélectionner le type de message", - "test": "Test", - "test-script-function": "Tester le script", - "type": "Type", - "type-action": "Action", - "type-action-details": "Effectuer une action spéciale", - "type-enrichment": "Enrichissement", - "type-enrichment-details": "Ajouter des informations supplémentaires dans les métadonnées de message", - "type-external": "Externe", - "type-external-details": "Interagit avec le système externe", - "type-filter": "Filtre", - "type-filter-details": "Filtrer les messages entrants avec des conditions configurées", - "type-input": "Input", - "type-input-details": "Entrée logique de la chaîne de règles, transmet les messages entrants au prochain nœud de règle associé", - "type-rule-chain": "Chaîne de règles", - "type-rule-chain-details": "Transmet les messages entrants à la chaîne de règles spécifiée", - "type-transformation": "Transformation", - "type-transformation-details": "Modifier le payload du message et les métadonnées ", - "type-unknown": "Inconnu", - "type-unknown-details": "Noeud de règle non résolu", - "ui-resources-load-error": "Impossible de charger les ressources de configuration de l'interface utilisateur." - }, -"tenant":{ - "add": "Ajouter un Tenant", - "add-tenant-text": "Ajouter un nouveau Tenant", - "admins": "Admins", - "copyId": "Copier l'Id du Tenant", - "delete": "Supprimer le Tenant", - "delete-tenant-text": "Attention, après la confirmation, le Tenant et toutes les données associées deviendront irrécupérables.", - "delete-tenant-title": "Etes-vous sûr de vouloir supprimer le tenant '{{tenantTitle}}'?", - "delete-tenants-action-title": "Supprimer {count, plural, 1 {1 tenant} other {# tenants}}", - "delete-tenants-text": "Attention, après la confirmation, tous les Tenants sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-tenants-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 tenant} other {# tenants}}?", - "description": "Description", - "details": "Détails", - "events": "Événements", - "idCopiedMessage": "L'Id du Tenant a été copié dans le Presse-papiers", - "manage-tenant-admins": "Gérer les administrateurs du Tenant", - "management": "Gestion des Tenants", - "no-tenants-matching": "Aucun Tenant correspondant à {{entity}} n'a été trouvé. ", - "no-tenants-text": "Aucun Tenant trouvé", - "select-tenant": "Sélectionner un Tenant", - "tenant": "Tenant", - "tenant-details": "Détails du Tenant", - "tenant-required": "Tenant requis", - "tenants": "Tenants", - "title": "Titre", - "title-required": "Le titre est requis." - }, -"timeinterval":{ - "advanced": "Avancé", - "days": "Jours", - "days-interval": "{days, plural, 1 {1 jour} other {# jours}}", - "hours": "Heures", - "hours-interval": "{hours, plural, 1 {1 heure} other {# heures}}", - "minutes": "Minutes", - "minutes-interval": "{minutes, plural, 1 {1 minute} other {# minutes}}", - "seconds": "Secondes", - "seconds-interval": "{seconds, plural, 1 {1 seconde} other {# secondes}}" - }, -"timewindow":{ - "date-range": "Plage de dates", - "days": "{days, plural, 1 {jour} other {# jours}}", - "edit": "Modifier timewindow", - "history": "Historique", - "hours": "{hours, plural, 0 {heure} 1 {1 heure} other {# heures}}", - "last": "Dernier", - "last-prefix": "dernier", - "minutes": "{minutes, plural, 0 {minute} 1 {1 minute} other {# minutes}}", - "period": "de {{startTime}} à {{endTime}}", - "realtime": "Temps réel", - "seconds": "{seconds, plural, 0 {second} 1 {1 second} other {# seconds}}", - "time-period": "Période" - }, -"user":{ - "activation-email-sent-message": "L'e-mail d'activation a été envoyé avec succès!", - "activation-link": "Lien d'activation utilisateur", - "activation-link-copied-message": "le lien d'activation de l'utilisateur a été copié dans le presse-papier", - "activation-link-text": "Pour activer l'utilisateur, utilisez le lien d'activation suivant: ", - "activation-method": "Méthode d'activation", - "add": "Ajouter un utilisateur", - "add-user-text": "Ajouter un nouvel utilisateur", - "always-fullscreen": "Toujours en plein écran", - "anonymous": "Anonyme", - "copy-activation-link": "Copier le lien d'activation", - "customer": "Client", - "customer-users": "Utilisateurs du client", - "default-dashboard": "Tableau de bord par défaut", - "delete": "Supprimer l'utilisateur", - "delete-user-text": "Attention, après la confirmation, l'utilisateur et toutes les données associées deviendront irrécupérables.", - "delete-user-title": "Etes-vous sûr de vouloir supprimer l'utilisateur '{{userEmail}}'?", - "delete-users-action-title": "Supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs}}", - "delete-users-text": "Attention, après la confirmation, tous les utilisateurs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-users-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs}}?", - "description": "Description", - "details": "Détails", - "display-activation-link": "Afficher le lien d'activation", - "email": "Email", - "email-required": "Email est requis.", - "first-name": "Prénom", - "invalid-email-format": "Format de courrier électronique non valide", - "last-name": "Nom de famille", - "no-users-matching": "Aucun utilisateur correspondant à '{{entity}}' n'a été trouvé.", - "no-users-text": "Aucun utilisateur trouvé", - "resend-activation": "Renvoyer l'activation", - "select-user": "Sélectionner l'utilisateur", - "send-activation-mail": "Envoyer un mail d'activation", - "sys-admin": "Administrateur du système", - "tenant-admin": "Administrateur du Tenant", - "tenant-admins": "administrateurs du Tenant", - "user": "utilisateur", - "user-details": "Détails de l'utilisateur", - "user-required": "L'utilisateur est requis", - "users": "Utilisateurs" - }, -"value":{ - "boolean": "booléen", - "boolean-value": "Valeur booléenne", - "double": "Double", - "double-value": "Valeur double", - "false": "Faux", - "integer": "Entier", - "integer-value": "Valeur entière", - "invalid-integer-value": "Valeur entière invalide", - "long": "Long", - "string": "String", - "string-value": "Valeur String", - "true": "Vrai", - "type": "Type de valeur" - }, -"widget":{ - "add": "Ajouter un widget", - "add-resource": "Ajouter une ressource", - "add-widget-type": "Ajouter un nouveau type de widget", - "alarm": "Widget d'alarme", - "css": "CSS", - "datakey-settings-schema": "Schéma des paramètres de Data key", - "edit": "Modifier le widget", - "editor": " Editeur de widget", - "export": "Exporter widget", - "html": "HTML", - "javascript": "Javascript", - "latest-values": "Dernières valeurs", - "management": "Gestion des widgets", - "missing-widget-title-error": "Le titre du widget doit être spécifié!", - "no-data-found": "Aucune donnée trouvée", - "remove": "Supprimer le widget", - "remove-resource": "Supprimer une ressource", - "remove-widget-text": "Après la confirmation, le widget et toutes les données associées deviendront irrécupérables.", - "remove-widget-title": "Êtes-vous sûr de vouloir supprimer le widget '{{widgetTitle}}'?", - "remove-widget-type": "Supprimer le type de widget", - "remove-widget-type-text": "Après la confirmation, le type de widget et toutes les données associées deviendront irrécupérables.", - "remove-widget-type-title": "Êtes-vous sûr de vouloir supprimer le type de widget '{{widgetName}}'?", - "resource-url": "URL JavaScript / CSS", - "resources": "Ressources", - "rpc": "Widget de contrôle", - "run": "Exécuter un widget", - "save": "Enregistrer le widget", - "save-widget-type-as": "Enregistrer le type de widget sous", - "save-widget-type-as-text": "Veuillez saisir un nouveau titre de widget et / ou sélectionner un ensemble de widgets cibles", - "saveAs": "Enregistrer le widget sous", - "search-data": "Rechercher des données", - "select-widget-type": "Sélectionnez le type de widget", - "select-widgets-bundle": "Sélectionner un ensemble de widgets", - "settings-schema": "Schéma des paramètres", - "static": "Widget statique", - "tidy": "Tidy", - "timeseries": "Séries chronologiques", - "title": "Titre du widget", - "title-required": "Le titre du widget est requis.", - "toggle-fullscreen": "Basculer le mode plein écran", - "type": "Type de widget", - "unable-to-save-widget-error": "Impossible de sauvegarder le widget! Le widget a des erreurs!", - "undo": "Annuler les modifications du widget", - "widget-bundle": "Ensemble de widget", - "widget-library": "Bibliothèque de widgets", - "widget-saved": "Widget enregistré", - "widget-template-load-failed-error": "Impossible de charger le modèle de widget!", - "widget-type-load-error": "Le widget n'a pas été chargé à cause des erreurs suivantes:", - "widget-type-load-failed-error": "Impossible de charger le type de widget!", - "widget-type-not-found": "Problème de chargement de la configuration du widget.
Le type de widget associé a probablement été supprimé." - }, -"widget-action":{ - "custom": "Action personnalisée", - "header-button": "Bouton d'en-tête de widget", - "open-dashboard": "Naviguer vers un autre tableau de bord", - "open-dashboard-state": "Naviguer vers un nouvel état du tableau de bord", - "open-right-layout": "Ouvrir la disposition du tableau de bord droite (vue mobile)", - "set-entity-from-widget": "Définir l'entité à partir du widget", - "target-dashboard": "Tableau de bord cible", - "target-dashboard-state": "Etat du tableau de bord cible", - "target-dashboard-state-required": "L'état du tableau de bord cible est requis", - "update-dashboard-state": "Mettre à jour l'état actuel du tableau de bord" - }, -"widget-config":{ - "action": "Action", - "action-icon": "Icône", - "action-name": "Nom", - "action-name-not-unique": "Une autre action portant le même nom existe déjà.
Le nom de l'action doit être unique dans la même source d'action.", - "action-name-required": "Le nom de l'action est requis", - "action-source": "Source de l'action", - "action-source-required": "Une source d'action est requise.", - "action-type": "Type", - "action-type-required": "Le type d'action est requis.", - "actions": "Actions", - "add-action": "Ajouter une action", - "add-datasource": "Ajouter une source de données", - "advanced": "Avancé", - "alarm-source": "Source d'alarme", - "background-color": "couleur de fond", - "data": "Données", - "datasource-parameters": "Paramètres", - "datasource-type": "Type", - "datasources": "Sources de données", - "decimals": "Nombre de chiffres après virgule flottante", - "delete-action": "Supprimer l'action", - "delete-action-text": "Etes-vous sûr de vouloir supprimer l'action du widget nommé '{{actionName}}'?", - "delete-action-title": "Supprimer l'action du widget", - "display-legend": "Afficher la légende", - "display-title": "Afficher le titre", - "drop-shadow": "Ombre portée", - "edit-action": "Modifier l'action", - "enable-fullscreen": "Activer le plein écran", - "general-settings": "Paramètres généraux", - "height": "Hauteur", - "margin": "Marge", - "maximum-datasources": "Maximum {count, plural, 1 {1 datasource est autorisé.} other {# datasources sont autorisés}}", - "mobile-mode-settings": "Paramètres du mode mobile", - "order": "Ordre", - "padding": "Padding", - "remove-datasource": "Supprimer la source de données", - "search-actions": "Recherche d'actions", - "settings": "Paramètres", - "target-device": "Dispositif cible", - "text-color": "Couleur du texte", - "timewindow": "Fenêtre de temps", - "title": "Titre", - "title-style": "Style de titre", - "units": "Symbole spécial à afficher à côté de la valeur", - "use-dashboard-timewindow": "Utiliser la fenêtre de temps du tableau de bord", - "widget-style": "Style du widget" - }, -"widget-type":{ - "create-new-widget-type": "Créer un nouveau type de widget", - "export": "Exporter le type de widget", - "export-failed-error": "Impossible d'exporter le type de widget: {{error}}", - "import": "Importer le type de widget", - "invalid-widget-type-file-error": "Impossible d'importer le type de widget: structure de données de type widget invalide.", - "widget-type-file": "Fichier de type Widget" - }, -"widgets-bundle":{ - "add": "Ajouter un groupe de widgets", - "add-widgets-bundle-text": "Ajouter un nouveau groupe de widgets", - "create-new-widgets-bundle": "Créer un nouveau groupe de widgets", - "current": "Groupe actuel", - "delete": "Supprimer le groupe de widgets", - "delete-widgets-bundle-text": "Attention, après la confirmation, le groupe de widgets et toutes les données associées deviendront irrécupérables.", - "delete-widgets-bundle-title": "Êtes-vous sûr de vouloir supprimer le groupe de widgets '{{widgetsBundleTitle}}'?", - "delete-widgets-bundles-action-title": "Supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets}}", - "delete-widgets-bundles-text": "Attention, après la confirmation, tous les groupes de widgets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", - "delete-widgets-bundles-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets}}?", - "details": "Détails", - "empty": "Le groupe de widgets est vide", - "export": "Exporter le groupe de widgets", - "export-failed-error": "Impossible d'exporter le groupe de widgets: {{error}}", - "import": "Importer un groupe de widgets", - "invalid-widgets-bundle-file-error": "Impossible d'importer un groupe de widgets: structure de données du groupe de widgets non valides.", - "no-widgets-bundles-matching": "Aucun groupe de widgets correspondant à {{widgetsBundle}} n'a été trouvé.", - "no-widgets-bundles-text": "Aucun groupe de widgets trouvé", - "system": "Système", - "title": "Titre", - "title-required": "Le titre est requis.", - "widgets-bundle-details": "Détails des groupes de widgets", - "widgets-bundle-file": "Fichier de groupe de widgets", - "widgets-bundle-required": "Un groupe de widgets est requis.", - "widgets-bundles": "Groupes de widgets" - } + "access": { + "access-forbidden": "Accès interdit", + "access-forbidden-text": "Vous n'avez pas accès à cet emplacement!
Essayez de vous connecter avec un autre utilisateur si vous souhaitez toujours accéder à cet emplacement.", + "refresh-token-expired": "La session a expiré", + "refresh-token-failed": "Impossible de rafraîchir la session", + "unauthorized": "non autorisé", + "unauthorized-access": "accès non autorisé", + "unauthorized-access-text": "Vous devez vous connecter pour avoir accès à cette ressource!" + }, + "action": { + "activate": "Activer", + "add": "Ajouter", + "apply": "Appliquer", + "apply-changes": "Appliquer les modifications", + "assign": "Attribuer", + "back": "retour", + "cancel": "Annuler", + "clear-search": "Effacer la recherche", + "close": "Fermer", + "copy": "Copier", + "copy-reference": "Copier la référence", + "create": "Créer", + "decline-changes": "Refuser les modifications", + "delete": "Supprimer", + "drag": "Drag", + "edit": "Modifier", + "edit-mode": "Mode édition", + "enter-edit-mode": "Entrer en mode édition", + "export": "Exporter", + "import": "Importer", + "make-private": "Rendre privé", + "no": "Non", + "ok": "OK", + "paste": "coller", + "paste-reference": "Coller référence", + "refresh": "Rafraîchir", + "remove": "Supprimer", + "run": "Exécuter", + "save": "Enregistrer", + "saveAs": "Enregistrer sous", + "search": "Rechercher", + "share": "Partager", + "share-via": "Partager via {{provider}}", + "sign-in": "Connectez-vous!", + "suspend": "Suspendre", + "unassign": "Retirer", + "undo": "Annuler", + "update": "mise à jour", + "view": "Afficher", + "yes": "Oui" + }, + "admin": { + "base-url": "URL de base", + "base-url-required": "L'URL de base est requise.", + "enable-tls": "Activer TLS", + "general": "Général", + "general-settings": "Paramètres généraux", + "mail-from": "Mail de", + "mail-from-required": "Mail de est requis.", + "outgoing-mail": "courrier sortant", + "outgoing-mail-settings": "Paramètres de courrier sortant", + "send-test-mail": "Envoyer un mail de test", + "smtp-host": "Hôte SMTP", + "smtp-host-required": "L'hôte SMTP est requis.", + "smtp-port": "Port SMTP", + "smtp-port-invalid": "Cela ne ressemble pas à un port smtp valide.", + "smtp-port-required": "Vous devez fournir un port smtp.", + "smtp-protocol": "Protocole SMTP", + "system-settings": "Paramètres système", + "test-mail-sent": "Le courrier de test a été envoyé avec succès!", + "timeout-invalid": "Cela ne ressemble pas à un délai d'expiration valide.", + "timeout-msec": "Délai (msec)", + "timeout-required": "Le délai est requis." + }, + "aggregation": { + "aggregation": "agrégation", + "avg": "Moyenne", + "count": "Compte", + "function": "Fonction d'agrégation de données", + "group-interval": "Intervalle de regroupement", + "limit": "Valeurs maximales", + "max": "Max", + "min": "Min", + "none": "Aucune", + "sum": "Somme" + }, + "alarm": { + "ack-time": "Heure d'acquittement", + "acknowledge": "Acquitter", + "aknowledge-alarms-text": "Etes-vous sûr de vouloir acquitter {count, plural, 1 {1 alarme} other {# alarmes}}?", + "aknowledge-alarms-title": "Acquitter {count, plural, 1 {1 alarme} other {# alarmes}}", + "alarm": "Alarme", + "alarm-details": "Détails de l'alarme", + "alarm-required": "Une alarme est requise", + "alarm-status": "Etat d'alarme", + "alarms": "Alarmes", + "clear": "Effacer", + "clear-alarms-text": "Êtes-vous sûr de vouloir effacer {count, plural, 1 {1 alarme} other {# alarmes}}?", + "clear-alarms-title": "Effacer {count, plural, 1 {1 alarme} other {# alarmes}}", + "clear-time": "Heure d'éffacement", + "created-time": "Heure de création", + "details": "Détails", + "display-status": { + "ACTIVE_ACK": "Active acquittée", + "ACTIVE_UNACK": "Active non acquittée", + "CLEARED_ACK": "effacée acquittée", + "CLEARED_UNACK": "effacée non acquittée" + }, + "end-time": "Heure de fin", + "min-polling-interval-message": "Un intervalle d'interrogation d'au moins 1 seconde est autorisé.", + "no-alarms-matching": "Aucune alarme correspondant à {{entity}} n'a été trouvée. ", + "no-alarms-prompt": "Aucune alarme trouvée", + "no-data": "Aucune donnée à afficher", + "originator": "Source", + "originator-type": "Type de Source", + "polling-interval": "Intervalle d'interrogation des alarmes (sec)", + "polling-interval-required": "L'intervalle d'interrogation des alarmes est requis.", + "search": "Rechercher des alarmes", + "search-status": { + "ACK": "acquitté", + "ACTIVE": "active", + "ANY": "Toutes", + "CLEARED": "effacée", + "UNACK": "non acquittée" + }, + "select-alarm": "Sélectionnez une alarme", + "selected-alarms": "{count, plural, 1 {1 alarme} other {# alarmes}} sélectionnées", + "severity": "Gravitée", + "severity-critical": "Critique", + "severity-indeterminate": "indéterminée", + "severity-major": "Majeure", + "severity-minor": "mineure", + "severity-warning": "Avertissement", + "start-time": "Heure de début", + "status": "Etat", + "type": "Type" + }, + "alias": { + "add": "Ajouter un alias", + "all-entities": "Toutes les entités", + "any-relation": "toutes", + "default-entity-parameter-name": "Par défaut", + "default-state-entity": "Entité d'état par défaut", + "duplicate-alias": "Un alias portant le même nom existe déjà.", + "edit": "Modifier l'alias", + "entity-filter": "Filtre d'entité", + "entity-filter-no-entity-matched": "Aucune entité correspondant au filtre spécifié n'a été trouvée.", + "filter-type": "Type de filtre", + "filter-type-asset-search-query": "requête de recherche d'Assets", + "filter-type-asset-search-query-description": "Assets de types {{assetTypes}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-asset-type": "type d'Asset", + "filter-type-asset-type-and-name-description": "Assets de type '{{assetType}}' et dont le nom commence par '{{prefix}}'", + "filter-type-asset-type-description": "Assets de type '{{assetType}}'", + "filter-type-device-search-query": "Requête de recherche de dispositif", + "filter-type-device-search-query-description": "Dispositifs de types {{deviceTypes}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-device-type": "Type de dispositif", + "filter-type-device-type-and-name-description": "Dispositifs de type '{{deviceType}}' et dont le nom commence par '{{prefix}}'", + "filter-type-device-type-description": "Dispositifs de type '{{deviceType}}'", + "filter-type-entity-list": "Liste d'entités", + "filter-type-entity-name": "Nom d'entité", + "filter-type-relations-query": "Interrogation des relations", + "filter-type-relations-query-description": "{{entities}} ayant {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-required": "Le type de filtre est requis.", + "filter-type-single-entity": "Entité unique", + "filter-type-state-entity": "Entité de l'état du tableau de bord", + "filter-type-state-entity-description": "Entité extraite des paramètres d'état du tableau de bord", + "max-relation-level": "Niveau de relation maximum", + "name": "Nom de l'alias", + "name-required": "Le nom d'alias est requis", + "no-entity-filter-specified": "Aucun filtre d'entité spécifié", + "resolve-multiple": "Résoudre en plusieurs entités", + "root-entity": "Entité racine", + "root-state-entity": "Utiliser l'entité d'état du tableau de bord en tant que racine", + "state-entity": "Entité d'état du tableau de bord", + "state-entity-parameter-name": "Nom du paramètre d'entité d'état", + "unlimited-level": "niveau illimité" + }, + "asset": { + "add": "Ajouter un Asset", + "add-asset-text": "Ajouter un nouvel Asset", + "any-asset": "Tout Asset", + "asset": "Asset", + "asset-details": "Détails de l'Asset", + "asset-public": "L'Asset est public", + "asset-required": "Asset requis", + "asset-type": "Type d'Asset", + "asset-type-list-empty": "Aucun type d'Asset sélectionné.", + "asset-type-required": "Le type d'Asset est requis.", + "asset-types": "Types d'Asset", + "assets": "Assets", + "assign-asset-to-customer": "Attribuer des Assets au client", + "assign-asset-to-customer-text": "Veuillez sélectionner les Assets à attribuer au client", + "assign-assets": "Attribuer des Assets", + "assign-assets-text": "Attribuer {count, plural, 1 {1 asset} other {# assets}} au client", + "assign-new-asset": "Attribuer un nouvel Asset", + "assign-to-customer": "Attribuer au client", + "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les Assets", + "assignedToCustomer": "attribué au client", + "copyId": "Copier l'Id de l'Asset", + "delete": "Supprimer un Asset", + "delete-asset-text": "Faites attention, après la confirmation, l'Asset et toutes les données associées deviendront irrécupérables.", + "delete-asset-title": "Êtes-vous sûr de vouloir supprimer l'Asset '{{assetName}}'?", + "delete-assets": "Supprimer des Assets", + "delete-assets-action-title": "Supprimer {count, plural, 1 {1 asset} other {# assets}}", + "delete-assets-text": "Attention, après la confirmation, tous les Assets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-assets-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 asset} other {# assets}}?", + "description": "Description", + "details": "Détails", + "enter-asset-type": "Entrez le type d'Asset", + "events": "Evènements", + "idCopiedMessage": "L'Id d'asset a été copié dans le presse-papier", + "make-private": "Rendre l'Asset privé", + "make-private-asset-text": "Après la confirmation, l'Asset et toutes ses données seront rendus privés et ne seront pas accessibles par d'autres.", + "make-private-asset-title": "Etes-vous sûr de vouloir rendre l'Asset '{{assetName}}' privé '?", + "make-public": "Rendre l'Asset public", + "make-public-asset-text": "Après la confirmation, l'asset et toutes ses données seront rendus publics et accessibles aux autres.", + "make-public-asset-title": "Êtes-vous sûr de vouloir rendre l'Asset '{{assetName}}' public '?", + "management": "Gestion d'Assets", + "name": "Nom", + "name-required": "Nom est requis.", + "name-starts-with": "Le nom de l'Asset commence par", + "no-asset-types-matching": "Aucun type d'Asset correspondant à {{entitySubtype}} n'a été trouvé. ", + "no-assets-matching": "Aucun Asset correspondant à {{entity}} n'a été trouvé. ", + "no-assets-text": "Aucun Asset trouvé", + "public": "Public", + "select-asset": "Sélectionner un Asset", + "select-asset-type": "Sélectionner le type d'Asset", + "type": "Type", + "type-required": "Le type est requis.", + "unassign-asset": "Retirer l'Asset", + "unassign-asset-text": "Après la confirmation, l'Asset sera non attribué et ne sera pas accessible au client.", + "unassign-asset-title": "Êtes-vous sûr de vouloir retirer l'attribution de l'Asset '{{assetName}}'?", + "unassign-assets": "Retirer les Assets", + "unassign-assets-action-title": "Retirer {count, plural, 1 {1 asset} other {# assets}} du client", + "unassign-assets-text": "Après la confirmation, tous les Assets sélectionnés ne seront pas attribués et ne seront pas accessibles au client.", + "unassign-assets-title": "Êtes-vous sûr de vouloir retirer l'attribution de {count, plural, 1 {1 asset} other {# assets}}?", + "unassign-from-customer": "Retirer du client", + "view-assets": "Afficher les Assets" + }, + "attribute": { + "add": "Ajouter un attribut", + "add-to-dashboard": "Ajouter au tableau de bord", + "add-widget-to-dashboard": "Ajouter un widget au tableau de bord", + "attributes": "Attributs", + "attributes-scope": "Etendue des attributs d'entité", + "delete-attributes": "Supprimer les attributs", + "delete-attributes-text": "Attention, après la confirmation, tous les attributs sélectionnés seront supprimés.", + "delete-attributes-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 attribut} other {# attributs}}?", + "enter-attribute-value": "Entrez la valeur de l'attribut", + "key": "Clé", + "key-required": "La Clé d'attribut est requise.", + "last-update-time": "Dernière mise à jour", + "latest-telemetry": "Dernière télémétrie", + "next-widget": "Widget suivant", + "prev-widget": "Widget précédent", + "scope-client": "Attributs du client", + "scope-latest-telemetry": "Dernière télémétrie", + "scope-server": "Attributs du serveur", + "scope-shared": "Attributs partagés", + "selected-attributes": "{count, plural, 1 {1 attribut} other {# attributs}} sélectionnés", + "selected-telemetry": "{count, plural, 1 {1 unité de télémétrie} other {# unités de télémétrie}} sélectionnées", + "show-on-widget": "Afficher sur le widget", + "value": "Valeur", + "value-required": "La valeur d'attribut est obligatoire.", + "widget-mode": "Mode du widget" + }, + "audit-log": { + "action-data": "Action data", + "audit": "Audit", + "audit-log-details": "Détails du journal d'audit", + "audit-logs": "Journaux d'audit", + "clear-search": "Effacer la recherche", + "details": "Détails", + "entity-name": "Nom de l'entité", + "entity-type": "Type d'entité", + "failure-details": "Détails de l'échec", + "no-audit-logs-prompt": "Aucun journal trouvé", + "search": "Rechercher les journaux d'audit", + "status": "Etat", + "status-failure": "Échec", + "status-success": "Succès", + "timestamp": "Horodatage", + "type": "Type", + "type-activated": "Activé", + "type-added": "Ajouté", + "type-alarm-ack": "Acquitté", + "type-alarm-clear": "Effacé", + "type-assigned-to-customer": "Attribué au client", + "type-attributes-deleted": "Attributs supprimés", + "type-attributes-read": "Attributs lus", + "type-attributes-updated": "Attributs mis à jour", + "type-credentials-read": "Lecture des informations d'identification", + "type-credentials-updated": "Informations d'identification actualisées", + "type-deleted": "Supprimé", + "type-relation-add-or-update": "Relation mise à jour", + "type-relation-delete": "Relation supprimée", + "type-relations-delete": "Toutes les relations ont été supprimées", + "type-rpc-call": "Appel RPC", + "type-suspended": "Suspendu", + "type-unassigned-from-customer": "Non attribué du client", + "type-updated": "Mise à jour", + "user": "Utilisateur" + }, + "common": { + "enter-password": "Entrez le mot de passe", + "enter-search": "Entrez la recherche", + "enter-username": "Entrez le nom d'utilisateur", + "password": "Mot de passe", + "username": "Nom d'utilisateur" + }, + "confirm-on-exit": { + "html-message": "Vous avez des modifications non enregistrées.
Êtes-vous sûr de vouloir quitter cette page?", + "message": "Vous avez des modifications non enregistrées. Êtes-vous sûr de vouloir quitter cette page?", + "title": "Modifications non enregistrées" + }, + "contact": { + "address": "Adresse", + "address2": "adresse 2", + "city": "Ville", + "country": "Pays", + "email": "Email", + "no-address": "Pas d'adresse", + "phone": "Téléphone", + "postal-code": "Code postal", + "postal-code-invalid": "Format de code postal / code postal invalide", + "state": "Etat / Province" + }, + "content-type": { + "binary": "Binaire (Base64)", + "json": "Json", + "text": "Texte" + }, + "custom": { + "widget-action": { + "action-cell-button": "Action cell button", + "marker-click": "On marker click", + "row-click": "On row click", + "tooltip-tag-action": "Tooltip tag action" + } + }, + "customer": { + "add": "Ajouter un client", + "add-customer-text": "Ajouter un nouveau client", + "assets": "Assets du client", + "copyId": "Copier l'id du client", + "customer": "Client", + "customer-details": "Détails du client", + "customer-required": "Le client est requis", + "customers": "Clients", + "dashboard": "Tableau de bord du client", + "dashboards": "tableaux de bord du client", + "default-customer": "Client par défaut", + "default-customer-required": "Le client par défaut est requis pour déboguer le tableau de bord au niveau du Tenant", + "delete": "Supprimer le client", + "delete-customer-text": "Faites attention, après la confirmation, le client et toutes les données associées deviendront irrécupérables.", + "delete-customer-title": "Êtes-vous sûr de vouloir supprimer le client '{{customerTitle}}'?", + "delete-customers-action-title": "Supprimer {count, plural, 1 {1 client} other {# clients}}", + "delete-customers-text": "Faites attention, après la confirmation, tous les clients sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-customers-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 client} other {# clients}}?", + "description": "Description", + "details": "Détails", + "devices": "Dispositifs du client", + "events": "Événements", + "idCopiedMessage": "L'Id du client a été copié dans le presse-papier", + "manage-assets": "Gérer les Assets", + "manage-customer-assets": "Gérer les Assets du client", + "manage-customer-dashboards": "Gérer les tableaux de bord du client", + "manage-customer-devices": "Gérer les dispositifs du client", + "manage-customer-users": "Gérer les utilisateurs du client", + "manage-dashboards": "Gérer les tableaux de bord", + "manage-devices": "Gérer les dispositifs", + "manage-public-assets": "Gérer les Assets publics", + "manage-public-dashboards": "Gérer les tableaux de bord publics", + "manage-public-devices": "Gérer les dispositifs publics", + "manage-users": "Gérer les utilisateurs", + "management": "Gestion des clients", + "no-customers-matching": "Aucun client correspondant à '{{entity}} n'a été trouvé.", + "no-customers-text": "Aucun client trouvé", + "public-assets": "Assets publics", + "public-dashboards": "Tableaux de bord publics", + "public-devices": "Dispositifs publics", + "select-customer": "Sélectionner un client", + "select-default-customer": "Sélectionnez le client par défaut", + "title": "Titre", + "title-required": "Le titre est requis." + }, + "dashboard": { + "add": "Ajouter un tableau de bord", + "add-dashboard-text": "Ajouter un nouveau tableau de bord", + "add-state": "Ajouter un état du tableau de bord", + "add-widget": "Ajouter un nouveau widget", + "alias-resolution-error-title": "Erreur de configuration des alias de tableau de bord", + "assign-dashboard-to-customer": "Attribuer des tableaux de bord au client", + "assign-dashboard-to-customer-text": "Veuillez sélectionner les tableaux de bord à affecter au client", + "assign-dashboards": "Attribuer des tableaux de bord", + "assign-dashboards-text": "Attribuer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} aux clients", + "assign-new-dashboard": "Attribuer un nouveau tableau de bord", + "assign-to-customer": "Attribuer au client", + "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les tableaux de bord", + "assign-to-customers": "Attribuer des tableaux de bord aux clients", + "assign-to-customers-text": "Veuillez sélectionner les clients pour attribuer les tableaux de bord", + "assigned-customers": "clients affectés", + "assignedToCustomer": "Attribué au client", + "assignedToCustomers": "attribué aux clients", + "autofill-height": "Hauteur de remplissage automatique", + "background-color": "Couleur de fond", + "background-image": "Image d'arrière-plan", + "background-size-mode": "Mode de taille d'arrière-plan", + "close-toolbar": "Fermer la barre d'outils", + "columns-count": "Nombre de colonnes", + "columns-count-required": "Le nombre de colonnes est requis.", + "configuration-error": "Erreur de configuration", + "copy-public-link": "Copier le lien public", + "create-new": "Créer un nouveau tableau de bord", + "create-new-dashboard": "Créer un nouveau tableau de bord", + "create-new-widget": "Créer un nouveau widget", + "dashboard": "Tableau de bord", + "dashboard-details": "Détails du tableau de bord", + "dashboard-file": "Fichier du tableau de bord", + "dashboard-import-missing-aliases-title": "Configurer les alias utilisés par le tableau de bord importé", + "dashboard-required": "Le tableau de bord est requis.", + "dashboards": "Tableaux de bord", + "delete": "Supprimer le tableau de bord", + "delete-dashboard-text": "Faites attention, après la confirmation, le tableau de bord et toutes les données associées deviendront irrécupérables.", + "delete-dashboard-title": "Êtes-vous sûr de vouloir supprimer le tableau de bord '{{dashboardTitle}}'?", + "delete-dashboards": "Supprimer les tableaux de bord", + "delete-dashboards-action-title": "Supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}", + "delete-dashboards-text": "Attention, après la confirmation, tous les tableaux de bord sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-dashboards-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}?", + "delete-state": "Supprimer l'état du tableau de bord", + "delete-state-text": "Etes-vous sûr de vouloir supprimer l'état du tableau de bord avec le nom '{{stateName}}'?", + "delete-state-title": "Supprimer l'état du tableau de bord", + "description": "Description", + "details": "Détails", + "display-dashboard-export": "Afficher l'exportation", + "display-dashboard-timewindow": "Afficher fenêtre de temps", + "display-dashboards-selection": "Afficher la sélection des tableaux de bord", + "display-entities-selection": "Afficher la sélection des entités", + "display-title": "Afficher le titre du tableau de bord", + "drop-image": "Déposer une image ou cliquez pour sélectionner un fichier à télécharger.", + "edit-state": "Modifier l'état du tableau de bord", + "export": "Exporter le tableau de bord", + "export-failed-error": "Impossible d'exporter le tableau de bord: {{error}}", + "hide-details": "Masquer les détails", + "horizontal-margin": "Marge horizontale", + "horizontal-margin-required": "Une valeur de marge horizontale est requise.", + "import": "Importer le tableau de bord", + "import-widget": "Importer un widget", + "invalid-aliases-config": "Impossible de trouver des dispositifs correspondant à certains filtres d'alias.
Veuillez contacter votre administrateur pour résoudre ce problème.", + "invalid-dashboard-file-error": "Impossible d'importer le tableau de bord: structure de données du tableau de bord non valide", + "invalid-widget-file-error": "Impossible d'importer le widget: structure de données de widget invalide.", + "is-root-state": "Etat racine", + "make-private": "Rendre privé le tableau de bord", + "make-private-dashboard": "Rendre privé le tableau de bord", + "make-private-dashboard-text": "Après la confirmation, le tableau de bord sera rendu privé et ne sera plus accessible aux autres.", + "make-private-dashboard-title": "Etes-vous sûr de vouloir rendre le tableau de bord '{{dashboardTitle}}' privé?", + "make-public": "Rendre public le tableau de bord", + "manage-assigned-customers": "Gérer les clients affectés", + "manage-states": "Gérer les états du tableau de bord", + "management": "Gestion du tableau de bord", + "max-columns-count-message": "Seulement 1000 colonnes maximum sont autorisées.", + "max-horizontal-margin-message": "Seulement 50 sont autorisés en tant que valeur de marge horizontale maximale.", + "max-mobile-row-height-message": "Seuls 200 pixels sont autorisés en tant que valeur maximale de hauteur de ligne mobile.", + "max-vertical-margin-message": "Seulement 50 sont autorisés en tant que valeur de marge verticale maximale.", + "min-columns-count-message": "Seul un nombre minimum de 10 colonnes est autorisé.", + "min-horizontal-margin-message": "Seul 0 est autorisé comme valeur de marge horizontale minimale.", + "min-mobile-row-height-message": "Seuls 5 pixels sont autorisés en tant que valeur minimale de hauteur de ligne mobile.", + "min-vertical-margin-message": "Seul 0 est autorisé comme valeur de marge verticale minimale.", + "mobile-layout": "Paramètres de mise en page mobiles", + "mobile-row-height": "Hauteur de ligne mobile, px", + "mobile-row-height-required": "Une valeur de hauteur de ligne mobile est requise.", + "new-dashboard-title": "Nouveau titre du tableau de bord", + "no-dashboards-matching": "Aucun tableau de bord correspondant à {{entity}} n'a été trouvé. ", + "no-dashboards-text": "Aucun tableau de bord trouvé", + "no-image": "Aucune image sélectionnée", + "no-widgets": "Aucun widget configuré", + "open-dashboard": "Ouvrir le tableau de bord", + "open-toolbar": "Ouvrir la barre d'outils du tableau de bord", + "public": "Public", + "public-dashboard-notice": " Remarque: N'oubliez pas de rendre publics les dispositifs associés pour accéder à leurs données.", + "public-dashboard-text": "Votre tableau de bord {{dashboardTitle}} est maintenant public et accessible via le lien public : ", + "public-dashboard-title": "Le tableau de bord est maintenant public", + "public-link": "Lien public", + "public-link-copied-message": "Le lien public du tableau de bord a été copié dans le presse-papier", + "search-states": "Recherche des états du tableau de bord", + "select-dashboard": "Sélectionner le tableau de bord", + "select-devices": "Selectionner les dispositifs", + "select-existing": "Sélectionnez un tableau de bord existant", + "select-state": "Sélectionnez l'état cible", + "select-widget-subtitle": "Liste des types de widgets disponibles", + "select-widget-title": "Sélectionner un widget", + "selected-states": "{count, plural, 1 {1 état du tableau de bord} other {# états du tableau de bord}} sélectionnés", + "set-background": "Définir l'arrière-plan", + "settings": "Paramètres", + "show-details": "Afficher les détails", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "state": "Etat du tableau de bord", + "state-controller": "Contrôleur d'état", + "state-id": "ID d'état", + "state-id-exists": "L'état du tableau de bord avec le même Id existe déjà.", + "state-id-required": "L'Id d'état du tableau de bord est requis.", + "state-name": "Nom", + "state-name-required": "Le nom de l'état du tableau de bord est requis", + "states": "Etats du tableau de bord", + "title": "Titre", + "title-color": "Couleur du titre", + "title-required": "Le titre est requis.", + "toolbar-always-open": "Garder la barre d'outils ouverte", + "unassign-dashboard": "Retirer le tableau de bord", + "unassign-dashboard-text": "Après la confirmation, le tableau de bord ne sera pas attribué et ne sera pas accessible au client.", + "unassign-dashboard-title": "Êtes-vous sûr de vouloir annuler l'affectation du tableau de bord '{{dashboardTitle}}'?", + "unassign-dashboards": "Retirer les tableaux de bord", + "unassign-dashboards-action-text": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} des clients", + "unassign-dashboards-action-title": "Annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}} du client", + "unassign-dashboards-text": "Après la confirmation, tous les tableaux de bord sélectionnés ne seront pas attribués et ne seront pas accessibles au client.", + "unassign-dashboards-title": "Etes-vous sûr de vouloir annuler l'affectation {count, plural, 1 {1 tableau de bord} other {# tableaux de bord}}?", + "unassign-from-customer": "Retirer du client", + "unassign-from-customers": "Retirer les tableaux de bord des clients", + "unassign-from-customers-text": "Veuillez sélectionner les clients à annuler l'affectation du ou des tableaux de bord", + "vertical-margin": "Marge verticale", + "vertical-margin-required": "Une valeur de marge verticale est requise", + "view-dashboards": "Afficher les tableaux de bord", + "widget-file": "Fichier du Widget", + "widget-import-missing-aliases-title": "Configurer les alias utilisés par le widget importé", + "widgets-margins": "Marge entre les widgets" + }, + "datakey": { + "advanced": "Avancé", + "alarm": "Champs d'alarme", + "alarm-fields-required": "Les champs d'alarme sont obligatoires.", + "attributes": "Attributs", + "color": "Couleur", + "configuration": "Configuration de la clé de données", + "data-generation-func": "Fonction de génération de données", + "decimals": "Nombre de chiffres après virgule flottante", + "function-types": "Types de fonctions", + "function-types-required": "Les types de fonctions sont obligatoires", + "label": "Label", + "maximum-function-types": "Maximum {count, plural, 1 {1 type de fonction est autorisé.} other {# types de fonctions sont autorisés}}", + "maximum-timeseries-or-attributes": "Maximum {count, plural, 1 {1 timeseries / attribut est autorisé.} other {# timeseries / attributs sont autorisés}}", + "settings": "Paramètres", + "timeseries": "Timeseries", + "timeseries-or-attributes-required": "Les timeseries / attributs d'entité sont obligatoires.", + "timeseries-required": "Les Timeseries de l'entité sont obligatoires.", + "units": "Symbole spécial à afficher à côté de la valeur", + "use-data-post-processing-func": "Utiliser la fonction de post-traitement des données" + }, + "datasource": { + "add-datasource-prompt": "Veuillez ajouter une source de données", + "name": "Nom", + "type": "Type de source de données" + }, + "datetime": { + "date-from": "Date de", + "date-to": "Date à", + "time-from": "Heure de", + "time-to": "Heure à" + }, + "details": { + "edit-mode": "Mode édition", + "toggle-edit-mode": "Activer le mode édition" + }, + "device": { + "access-token": "Jeton d'accès", + "access-token-invalid": "La longueur du jeton d'accès doit être comprise entre 1 et 20 caractères.", + "access-token-required": "Le jeton d'accès est requis.", + "accessTokenCopiedMessage": "Le jeton d'accès au dispositif a été copié dans le presse-papier", + "add": "Ajouter un dispositif", + "add-alias": "Ajouter un alias de dispositif", + "add-device-text": "Ajouter un nouveau dispositif", + "alias": "Alias", + "alias-required": "Un alias du dispositif est requis.", + "aliases": "Alias ​​du dispositif", + "any-device": "N'importe quel dispositif", + "assign-device-to-customer": "Affecter des dispositifs au client", + "assign-device-to-customer-text": "Veuillez sélectionner les dispositif à affecter au client", + "assign-devices": "Attribuer des dispositifs", + "assign-devices-text": "Attribuer {count, plural, 1 {1 dispositif} other {# dispositifs}} au client", + "assign-new-device": "Attribuer un nouveau dispositif", + "assign-to-customer": "Attribuer au client", + "assign-to-customer-text": "Veuillez sélectionner le client pour attribuer le ou les dispositifs", + "assignedToCustomer": "Attribué au client", + "configure-alias": "Configurer '{{alias}}' alias", + "copyAccessToken": "Copier le jeton d'accès", + "copyId": "Copier l'Id du dispositif", + "create-new-alias": "Créez un nouveau!", + "create-new-key": "Créez un nouveau!", + "credentials": "Informations d'identification", + "credentials-type": "Type d'identification", + "delete": "Supprimer le dispositif", + "delete-device-text": "Faites attention, après la confirmation, le dispositif et toutes les données associées deviendront irrécupérables.", + "delete-device-title": "Êtes-vous sûr de vouloir supprimer le dispositif '{{deviceName}}'?", + "delete-devices": "Supprimer les dispositifs", + "delete-devices-action-title": "Supprimer {count, plural, 1 {1 dispositif} other {# dispositifs}}", + "delete-devices-text": "Faites attention, après la confirmation, tous les dispositifs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-devices-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 dispositif} other {# dispositifs}}?", + "description": "Description", + "details": "Détails", + "device": "Dispositif", + "device-alias": "Alias ​​du dispositif", + "device-credentials": "Informations d'identification du dispositif", + "device-details": "Détails du dispositif", + "device-list": "Liste des dispositifs", + "device-list-empty": "Aucun dispositif sélectionné.", + "device-name-filter-no-device-matched": "Aucun dispositif commençant par '{{device}} n'a été trouvé.", + "device-name-filter-required": "Le filtre de nom de dispositif est requis.", + "device-public": "Le dispositif est public", + "device-required": "Le dispositif est requis.", + "device-type": "Type de dispositif", + "device-type-list-empty": "Aucun type de dispositif sélectionné.", + "device-type-required": "Le type de dispositif est requis.", + "device-types": "Types de dispositif", + "devices": "Dispositifs", + "duplicate-alias-error": "Alias ​​en double trouvé '{{alias}}'.
Les alias de dispositifs doivent être uniques dans le tableau de bord.", + "enter-device-type": "Entrez le type de dispositif", + "events": "Événements", + "idCopiedMessage": "l'Id du dispositif a été copié dans le presse-papiers", + "is-gateway": "Est une passerelle", + "make-private": "Rendre le dispositif privé", + "make-private-device-text": "Après la confirmation, le dispositif et toutes ses données seront rendues privées et ne seront pas accessibles par d'autres.", + "make-private-device-title": "Etes-vous sûr de vouloir rendre le dispositif {{deviceName}} privé?", + "make-public": "Rendre le dispositif public", + "make-public-device-text": "Après la confirmation, le dispositif et toutes ses données seront rendus publics et accessibles par d'autres.", + "make-public-device-title": "Êtes-vous sûr de vouloir rendre le dispositif {{deviceName}} 'public?", + "manage-credentials": "Gérer les informations d'identification", + "management": "Gestion des dispositifs", + "name": "Nom", + "name-required": "Le nom est requis.", + "name-starts-with": "Le nom du dispositif commence par", + "no-alias-matching": "'{{alias}}' introuvable.", + "no-aliases-found": "Aucun alias trouvé.", + "no-device-types-matching": "Aucun type de dispositif correspondant à {{entitySubtype}} n'a été trouvé.", + "no-devices-matching": "Aucun dispositif correspondant à '{{entity}} n'a été trouvé.", + "no-devices-text": "Aucun dispositif trouvé", + "no-key-matching": "'{{key}}' introuvable.", + "no-keys-found": "Aucune clé trouvée", + "public": "Public", + "remove-alias": "Supprimer l'alias du dispositif", + "rsa-key": "Clé publique RSA", + "rsa-key-required": "La clé publique RSA est requise.", + "secret": "Secret", + "secret-required": "Code secret est requis.", + "select-device": "Selectionner un dispositif", + "select-device-type": "Sélectionner le type d'appareil", + "unable-delete-device-alias-text": "L'alias du dispositif '{{deviceAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
{{widgetsList}}", + "unable-delete-device-alias-title": "Impossible de supprimer l'alias du dispositif", + "unassign-device": "Annuler l'affectation du dispositif", + "unassign-device-text": "Après la confirmation, le dispositif ne sera pas attribué et ne sera pas accessible au client.", + "unassign-device-title": "Êtes-vous sûr de vouloir annuler l'affection du dispositif {{deviceName}} '?", + "unassign-devices": "Annuler l'affectation des dispositifs", + "unassign-devices-action-title": "Annuler l'affectation de {count, plural, 1 {1 dispositif} other {#dispositifs}} du client", + "unassign-devices-text": "Après la confirmation, tous les dispositifs sélectionnés ne seront pas attribues et ne seront pas accessibles par le client.", + "unassign-devices-title": "Voulez-vous vraiment annuler l'affectation de {count, plural, 1 {1 dispositif} other {# dispositifs}}?", + "unassign-from-customer": "Retirer du client", + "use-device-name-filter": "Utiliser le filtre", + "view-credentials": "Afficher les informations d'identification", + "view-devices": "Afficher les dispositifs" + }, + "dialog": { + "close": "Fermer le dialogue" + }, + "entity": { + "add-alias": "Ajouter un alias d'entité", + "alarm-name-starts-with": "Les alarmes dont le nom commence par '{{prefix}}'", + "alias": "Alias", + "alias-required": "Un alias d'entité est requis.", + "aliases": "alias d'entité", + "all-subtypes": "Tout", + "any-entity": "Toute entité", + "asset-name-starts-with": "Les Assets dont le nom commence par '{{prefix}}'", + "configure-alias": "Configurer '{{alias}}' alias", + "create-new-alias": "Créez un nouveau!", + "create-new-key": "Créez un nouveau!", + "customer-name-starts-with": "Les clients dont les noms commencent par '{{prefix}}'", + "dashboard-name-starts-with": "Les tableaux de bord dont les noms commencent par '{{prefix}}'", + "details": "Détails de l'entité", + "device-name-starts-with": "Dispositifs dont le nom commence par '{{prefix}}'", + "duplicate-alias-error": "Alias ​​en double trouvé '{{alias}}'.
Les alias d'entité doivent être uniques dans le tableau de bord.", + "enter-entity-type": "Entrez le type d'entité", + "entities": "Entités", + "entity": "Entité", + "entity-alias": "Alias de l'entité", + "entity-list": "Liste d'entités", + "entity-list-empty": "Aucune entité sélectionnée.", + "entity-name": "Nom de l'entité", + "entity-name-filter-no-entity-matched": "Aucune entité commençant par '{{entity}}' n'a été trouvée.", + "entity-name-filter-required": "Le filtre de nom d'entité est requis.", + "entity-type": "Type d'entité", + "entity-type-list": "Liste de types d'entités", + "entity-type-list-empty": "Aucun type d'entité sélectionné.", + "entity-types": "Types d'entité", + "key": "Clé", + "key-name": "Nom de la clé", + "list-of-alarms": "{count, plural, 1 {Une alarme} other {Liste de # alarmes}}", + "list-of-assets": "{count, plural, 1 {Un Asset} other {Liste de # Assets}}", + "list-of-customers": "{count, plural, 1 {Un client} other {Liste de # clients}}", + "list-of-dashboards": "{count, plural, 1 {Un tableau de bord} other {Liste de # tableaux de bord}}", + "list-of-devices": "{count, plural, 1 {Un dispositif} other {Liste de # dispositifs}}", + "list-of-plugins": "{count, plural, 1 {Un plugin} other {Liste de # plugins}}", + "list-of-rulechains": "{count, plural, 1 {Une chaîne de règles} other {Liste de # chaînes de règles}}", + "list-of-rulenodes": "{count, plural, 1 {Un noeud de règles} other {Liste de # noeuds de règles}}", + "list-of-rules": "{count, plural, 1 {Une règle} other {Liste de # règles}}", + "list-of-tenants": "{count, plural, 1 {Un tenant} other {Liste de # tenants}}", + "list-of-users": "{count, plural, 1 {Un utilisateur} other {Liste de # utilisateurs}}", + "missing-entity-filter-error": "Le filtre est manquant pour l'alias '{{alias}}'.", + "name-starts-with": "Nom commence par", + "no-alias-matching": "'{{alias}}' introuvable.", + "no-aliases-found": "Aucun alias trouvé.", + "no-data": "Aucune donnée à afficher", + "no-entities-matching": "Aucune entité correspondant à '{{entity}}' n'a été trouvée.", + "no-entities-prompt": "Aucune entité trouvée", + "no-entity-types-matching": "Aucun type d'entité correspondant à {{entityType}} n'a été trouvé. ", + "no-key-matching": "'{{key}}' introuvable.", + "no-keys-found": "Aucune clé trouvée", + "plugin-name-starts-with": "Plugins dont les noms commencent par '{{prefix}}'", + "remove-alias": "Supprimer l'alias d'entité", + "rule-name-starts-with": "Règles dont les noms commencent par '{{prefix}}'", + "rulechain-name-starts-with": "Chaînes de règles dont les noms commencent par '{{prefix}}'", + "rulenode-name-starts-with": "Les noeuds de règles dont le nom commence par '{{prefix}}'", + "search": "Recherche d'entités", + "select-entities": "Sélectionner des entités", + "selected-entities": "{count, plural, 1 {1 entité} other {# entités}} sélectionnées", + "tenant-name-starts-with": "Les Tenant dont le nom commence par '{{prefix}}'", + "type": "Type", + "type-alarm": "Alarme", + "type-alarms": "Alarmes", + "type-asset": "Asset", + "type-assets": "Assets", + "type-current-customer": "Client actuel", + "type-customer": "Client", + "type-customers": "Clients", + "type-dashboard": "Tableau de bord", + "type-dashboards": "Tableaux de bord", + "type-device": "Dispositif", + "type-devices": "Dispositifs", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "type-required": "Le type d'entité est obligatoire.", + "type-rule": "Règle", + "type-rulechain": "Chaîne de règles", + "type-rulechains": "Chaînes de règles", + "type-rulenode": "Noeud de règle", + "type-rulenodes": "Noeuds de règle", + "type-rules": "Règles", + "type-tenant": "Tenant", + "type-tenants": "Tenants", + "type-user": "Utilisateur", + "type-users": "Utilisateurs", + "unable-delete-entity-alias-text": "L'alias d'entité '{{entityAlias}}' ne peut pas être supprimé car il est utilisé par les widgets suivants:
{{widgetsList}}", + "unable-delete-entity-alias-title": "Impossible de supprimer l'alias d'entité", + "use-entity-name-filter": "Utiliser un filtre", + "user-name-starts-with": "Utilisateurs dont les noms commencent par '{{prefix}}'" + }, + "error": { + "unable-to-connect": "Impossible de se connecter au serveur! Veuillez vérifier votre connexion Internet.", + "unhandled-error-code": "Code d'erreur non géré: {{errorCode}}", + "unknown-error": "Erreur inconnue" + }, + "event": { + "alarm": "Alarme", + "body": "Corps", + "data": "Données", + "data-type": "Type de données", + "entity": "Entité", + "error": "erreur", + "errors-occurred": "Des erreurs sont survenues", + "event": "événement", + "event-time": "Heure de l'événement", + "event-type": "Type d'événement", + "failed": "Échec", + "message-id": "Message Id", + "message-type": "Type de message", + "messages-processed": "Messages traités", + "metadata": "Métadonnées", + "method": "Méthode", + "no-events-prompt": "Aucun événement trouvé", + "relation-type": "Type de relation", + "server": "Serveur", + "status": "Etat", + "success": "Succès", + "type": "Type", + "type-debug-rule-chain": "Debug", + "type-debug-rule-node": "Debug", + "type-error": "Erreur", + "type-lc-event": "Evénement du cycle de vie", + "type-stats": "Statistiques" + }, + "extension": { + "add": "Ajouter une extension", + "add-attribute": "Ajouter un attribut", + "add-attribute-request": "Ajouter une demande d'attribut", + "add-attribute-update": "Ajouter une mise à jour d'attribut", + "add-broker": "Ajouter un Broker", + "add-config": "Ajouter une configuration de convertisseur", + "add-connect-request": "Ajouter une demande de connexion", + "add-converter": "Ajouter un convertisseur", + "add-device": "Ajouter un dispositif", + "add-disconnect-request": "Ajouter une demande de déconnexion", + "add-map": "Ajouter un élément de mappage", + "add-server-side-rpc-request": "Ajouter une requête RPC côté serveur", + "add-timeseries": "Ajouter des timeseries", + "anonymous": "Anonyme", + "attr-json-key-expression": "Expression json de la clé d'attribut", + "attr-topic-key-expression": "Expression du topic de la clé d'attribut", + "attribute-filter": "Filtre d'attribut", + "attribute-key-expression": "Expression de clé d'attribut", + "attribute-requests": "Demandes d'attributs", + "attribute-updates": "Mises à jour des attributs", + "attributes": "Attributs", + "basic": "Basic", + "brokers": "Brokers", + "ca-cert": "Fichier de certificat CA", + "cert": "Fichier de certificat *", + "client-scope": "Portée client", + "configuration": "Configuration", + "connect-requests": "Demandes de connexion", + "converter-configurations": "Configurations du convertisseur", + "converter-id": "ID du convertisseur", + "converter-json": "Json", + "converter-json-parse": "Impossible d'analyser le convertisseur json.", + "converter-json-required": "Le convertisseur json est requis.", + "converter-type": "Type de convertisseur", + "converters": "Convertisseurs", + "credentials": "Informations d'identification", + "custom": "Custom", + "delete": "Supprimer l'extension", + "delete-extension-text": "Attention, après la confirmation, l'extension et toutes les données associées deviendront irrécupérables.", + "delete-extension-title": "Êtes-vous sûr de vouloir supprimer l'extension '{{extensionId}}'?", + "delete-extensions-text": "Attention, après la confirmation, toutes les extensions sélectionnées seront supprimées.", + "delete-extensions-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 extension} other {# extensions}}?", + "device-name-expression": "expression du nom du dispositif", + "device-name-filter": "Filtre de nom de dispositif", + "device-type-expression": "expression de type de dispositif", + "disconnect-requests": "Demandes de déconnection", + "drop-file": "Déposez un fichier ou cliquez pour sélectionner un fichier à télécharger.", + "edit": "Modifier l'extension", + "export-extension": "Exporter l'extension", + "export-extensions-configuration": "Exporter la configuration des extensions", + "extension-id": "Id de l'extension", + "extension-type": "Type d'extension", + "extensions": "Extensions", + "field-required": "Le champ est obligatoire", + "file": "Fichier d'extensions", + "filter-expression": "Expression du filtre", + "host": "Hôte", + "id": "Id", + "import-extension": "Importer une extension", + "import-extensions": "Importer des extensions", + "import-extensions-configuration": "Importer la configuration des extensions", + "invalid-file-error": "Fichier d'extension non valide", + "json-name-expression": "Expression json du nom du dispositif", + "json-parse": "Impossible d'analyser json transformer.", + "json-required": "Transformer json est requis.", + "json-type-expression": "Expression json du type de dispositif", + "key": "Clé", + "mapping": "Mappage", + "method-filter": "Filtre de méthode", + "modbus-add-server": "Ajouter serveur/esclave", + "modbus-add-server-prompt": "Veuillez ajouter serveur/esclave", + "modbus-attributes-poll-period": "Période d'interrogation des attributs (ms)", + "modbus-baudrate": "Débit en bauds", + "modbus-byte-order": "Ordre des octets", + "modbus-databits": "Bits de données", + "modbus-databits-range": "Les bits de données doivent être compris entre 7 et 8.", + "modbus-device-name": "Nom du dispositif", + "modbus-encoding": "Encodage", + "modbus-function": "Fonction", + "modbus-parity": "parité", + "modbus-poll-period": "Période d'interrogation (ms)", + "modbus-poll-period-range": "La période d'interrogation doit être une valeur positive.", + "modbus-port-name": "Nom du port série", + "modbus-register-address": "Adresse du registre", + "modbus-register-address-range": "L'adresse du registre doit être comprise entre 0 et 65535.", + "modbus-register-bit-index": "Bit index", + "modbus-register-bit-index-range": "L'index de bit doit être compris entre 0 et 15.", + "modbus-register-count": "Nombre de registre", + "modbus-register-count-range": "Le nombre de registres doit être une valeur positive.", + "modbus-server": "Serveurs / esclaves", + "modbus-stopbits": "Bits d'arrêt", + "modbus-stopbits-range": "Les bits d'arrêt doivent être compris entre 1 et 2.", + "modbus-tag": "Tag", + "modbus-timeseries-poll-period": "Période d'interrogation des Timeseries (ms)", + "modbus-transport": "Transport", + "modbus-unit-id": "Id de l'unité", + "modbus-unit-id-range": "L'ID de l'unité doit être compris entre 1 et 247.", + "no-file": "Aucun fichier sélectionné.", + "opc-add-server": "Ajouter un serveur", + "opc-add-server-prompt": "Veuillez ajouter un serveur", + "opc-application-name": "Nom de l'application", + "opc-application-uri": "Uri de l'application", + "opc-device-name-pattern": "modèle de nom du dispositif", + "opc-device-node-pattern": "modèle de noeud de dispositif", + "opc-identity": "Identité", + "opc-keystore": "Magasin de clés", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Mot de passe de la clé", + "opc-keystore-location": "Emplacement *", + "opc-keystore-password": "Mot de passe", + "opc-keystore-type": "Type", + "opc-scan-period-in-seconds": "Période d'analyse en secondes", + "opc-security": "Sécurité", + "opc-server": "Serveurs", + "opc-type": "Type", + "password": "Mot de passe", + "pem": "PEM", + "port": "Port", + "port-range": "Le port doit être compris entre 1 et 65535.", + "private-key": "Fichier de clé privée *", + "request-id-expression": "Expression de demande d'id", + "request-id-json-expression": "Expression json de la demande d'id", + "request-id-topic-expression": "Expression de la demande d'id du topic", + "request-topic-expression": "Expression de la demande du topic", + "response-timeout": "Délai de réponse en millisecondes", + "response-topic-expression": "Expression du topic de la réponse", + "retry-interval": "Intervalle de nouvelle tentative en millisecondes", + "selected-extensions": "{count, plural, 1 {1 extension} other {# extensions}} sélectionné", + "server-side-rpc": "RPC côté serveur", + "ssl": "Ssl", + "sync": { + "last-sync-time": "Dernière heure de synchronisation", + "not-available": "Non disponible", + "not-sync": "Non sync", + "status": "Status", + "sync": "Sync" + }, + "timeout": "Délai d'attente en millisecondes", + "timeseries": "Timeseries", + "to-double": "To Double", + "token": "Jeton de sécurité", + "topic": "Topic", + "topic-expression": "Expression du topic", + "topic-filter": "Filtre du topic", + "topic-name-expression": "Expression du nom du dispositif (topic)", + "topic-type-expression": "Expression de type de dispositif (topic)", + "transformer": "Transformer", + "transformer-json": "JSON *", + "type": "Type", + "unique-id-required": "L'identifiant d'extension actuel existe déjà.", + "username": "Nom d'utilisateur", + "value": "Valeur", + "value-expression": "Expression de la valeur" + }, + "fullscreen": { + "exit": "Quitter le plein écran", + "expand": "Afficher en plein écran", + "fullscreen": "Plein écran", + "toggle": "Activer le mode plein écran" + }, + "function": { + "function": "Fonction" + }, + "grid": { + "add-item-text": "Ajouter un nouvel élément", + "delete-item": "Supprimer l'élément", + "delete-item-text": "Faites attention, après la confirmation, cet élément et toutes les données associées deviendront irrécupérables.", + "delete-item-title": "Êtes-vous sûr de vouloir supprimer cet élément?", + "delete-items": "Supprimer les éléments", + "delete-items-action-title": "Supprimer {count, plural, 1 {1 élément} other {# éléments}}", + "delete-items-text": "Attention, après la confirmation, tous les éléments sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-items-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 élément} other {# éléments}}?", + "item-details": "Détails de l'élément", + "no-items-text": "Aucun élément trouvé", + "scroll-to-top": "Défiler vers le haut" + }, + "help": { + "goto-help-page": "Aller à la page d'aide" + }, + "home": { + "avatar": "Avatar", + "home": "Accueil", + "logout": "Déconnexion", + "menu": "Menu", + "open-user-menu": "Ouvrir le menu utilisateur", + "profile": "Profile" + }, + "icon": { + "icon": "Icône", + "material-icons": "Material icons", + "select-icon": "Sélectionner l'icône", + "show-all": "Afficher toutes les icônes" + }, + "import": { + "drop-file": "Déposez un fichier JSON ou cliquez pour sélectionner un fichier à télécharger.", + "no-file": "Aucun fichier sélectionné" + }, + "item": { + "selected": "Sélectionné" + }, + "js-func": { + "no-return-error": "La fonction doit renvoyer une valeur!", + "return-type-mismatch": "La fonction doit renvoyer une valeur de type '{{type}}' !", + "tidy": "Tidy" + }, + "key-val": { + "add-entry": "Ajouter une entrée", + "key": "Clé", + "no-data": "Aucune entrée", + "remove-entry": "Supprimer l'entrée", + "value": "Valeur" + }, + "language": { + "language": "Language", + "locales": { + "en_US": "Anglais", + "fr_FR": "Français", + "es_ES": "Espagnol", + "it_IT": "Italien", + "ko_KR": "Coréen", + "ru_RU": "Russe", + "zh_CN": "Chinois", + "tr_TR": "Turc" + } + }, + "layout": { + "color": "Couleur", + "layout": "Mise en page", + "main": "Principal", + "manage": "Gérer les mises en page", + "right": "Droite", + "select": "Sélectionner la mise en page cible", + "settings": "Paramètres de mise en page" + }, + "legend": { + "avg": "avg", + "max": "max", + "min": "min", + "position": "Position de la légende", + "settings": "Paramètres de la légende", + "show-avg": "Afficher la valeur moyenne", + "show-max": "Afficher la valeur maximale", + "show-min": "Afficher la valeur min", + "show-total": "Afficher la valeur totale", + "total": "total" + }, + "login": { + "create-password": "Créer un mot de passe", + "email": "Email", + "forgot-password": "Mot de passe oublié?", + "login": "Login", + "new-password": "Nouveau mot de passe", + "new-password-again": "nouveau mot de passe", + "password-again": "Mot de passe à nouveau", + "password-link-sent-message": "Le lien de réinitialisation du mot de passe a été envoyé avec succès!", + "password-reset": "Mot de passe réinitialisé", + "passwords-mismatch-error": "Les mots de passe saisis doivent être identiques!", + "remember-me": "Se souvenir de moi", + "request-password-reset": "Demander la réinitialisation du mot de passe", + "reset-password": "Réinitialiser le mot de passe", + "sign-in": "Veuillez vous connecter", + "username": "Nom d'utilisateur (email)" + }, + "position": { + "bottom": "Bas", + "left": "Gauche", + "right": "Droite", + "top": "Haut" + }, + "profile": { + "change-password": "Modifier le mot de passe", + "current-password": "Mot de passe actuel", + "profile": "Profile" + }, + "relation": { + "add": "Ajouter une relation", + "add-relation-filter": "Ajouter un filtre de relation", + "additional-info": "Informations supplémentaires (JSON)", + "any-relation": "toute relation", + "any-relation-type": "N'importe quel type", + "delete": "Supprimer la relation", + "delete-from-relation-text": "Attention, après la confirmation, l'entité actuelle ne sera pas liée à l'entité '{{entityName}}'.", + "delete-from-relation-title": "Etes-vous sûr de vouloir supprimer la relation de l'entité '{{entityName}}'?", + "delete-from-relations-text": "Attention, après la confirmation, toutes les relations sélectionnées seront supprimées et l'entité actuelle ne sera pas liée aux entités correspondantes.", + "delete-from-relations-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations}}?", + "delete-to-relation-text": "Attention, après la confirmation, l'entité '{{entityName}} ne sera plus liée à l'entité actuelle.", + "delete-to-relation-title": "Êtes-vous sûr de vouloir supprimer la relation avec l'entité '{{entityName}}'?", + "delete-to-relations-text": "Attention, après la confirmation, toutes les relations sélectionnées seront supprimées et les entités correspondantes ne seront pas liées à l'entité en cours.", + "delete-to-relations-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 relation} other {# relations}}?", + "direction": "Sens", + "direction-type": { + "FROM": "de", + "TO": "à" + }, + "edit": "Modifier la relation", + "from-entity": "De l'entité", + "from-entity-name": "Du nom d'entité", + "from-entity-type": "Du type d'entité", + "from-relations": "Relations sortantes", + "invalid-additional-info": "Impossible d'analyser les informations supplémentaires json.", + "relation-filters": "Filtres de relation", + "relation-type": "Type de relation", + "relation-type-required": "Le type de relation est requis.", + "relations": "Relations", + "remove-relation-filter": "Supprimer le filtre de relation", + "search-direction": { + "FROM": "De", + "TO": "À" + }, + "selected-relations": "{count, plural, 1 {1 relation} other {# relations}} sélectionné", + "to-entity": "À l'entité", + "to-entity-name": "vers le nom de l'entité", + "to-entity-type": "Vers le type d'entité", + "to-relations": "Relations entrantes", + "type": "Type" + }, + "rulechain": { + "add": "Ajouter une chaîne de règles", + "add-rulechain-text": "Ajouter une nouvelle chaîne de règles", + "copyId": "Copier l'identifiant de la chaîne de règles", + "create-new-rulechain": "Créer une nouvelle chaîne de règles", + "debug-mode": "Mode de débogage", + "delete": "Supprimer la chaîne de règles", + "delete-rulechain-text": "Attention, après la confirmation, la chaîne de règles et toutes les données associées deviendront irrécupérables.", + "delete-rulechain-title": "Voulez-vous vraiment supprimer la chaîne de règles '{{ruleChainName}}'?", + "delete-rulechains-action-title": "Supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles}}", + "delete-rulechains-text": "Attention, après la confirmation, toutes les chaînes de règles sélectionnées seront supprimées et toutes les données associées deviendront irrécupérables.", + "delete-rulechains-title": "Êtes-vous sûr de vouloir supprimer {count, plural, 1 {1 chaîne de règles} other {# chaînes de règles}}?", + "description": "Description", + "details": "Détails", + "events": "Evénements", + "export": "Exporter la chaîne de règles", + "export-failed-error": "Impossible d'exporter la chaîne de règles: {{error}}", + "idCopiedMessage": "L'ID de la chaîne de règles a été copié dans le presse-papier", + "import": "Importer la chaîne de règles", + "invalid-rulechain-file-error": "Impossible d'importer la chaîne de règles: structure de données de la chaîne de règles non valide", + "management": "Gestion des règles", + "name": "Nom", + "name-required": "Le nom est requis.", + "no-rulechains-matching": "Aucune chaîne de règles correspondant à {{entity}} n'a été trouvée.", + "no-rulechains-text": "Aucune chaîne de règles trouvée", + "root": "Racine", + "rulechain": "Chaîne de règles", + "rulechain-details": "Détails de la chaîne de règles", + "rulechain-file": "Fichier de chaîne de règles", + "rulechain-required": "Chaîne de règles requise", + "rulechains": "Chaînes de règles", + "select-rulechain": "Sélectionner la chaîne de règles", + "set-root": "Rend la chaîne de règles racine (root) ", + "set-root-rulechain-text": "Après la confirmation, la chaîne de règles deviendra racine (root) et gérera tous les messages de transport entrants.", + "set-root-rulechain-title": "Voulez-vous vraiment que la chaîne de règles '{{ruleChainName}} soit racine (root) ?", + "system": "Système" + }, + "rulenode": { + "add": "Ajouter un noeud de règle", + "add-link": "Ajouter un lien", + "configuration": "Configuration", + "copy-selected": "Copier les éléments sélectionnés", + "create-new-link-label": "Créez un nouveau!", + "custom-link-label": "Etiquette de lien personnalisée", + "custom-link-label-required": "Une étiquette de lien personnalisée est requise", + "debug-mode": "Mode de débogage", + "delete": "Supprimer le noeud de règle", + "delete-selected": "Supprimer les éléments sélectionnés", + "delete-selected-objects": "Supprimer les nœuds et les connexions sélectionnés", + "description": "Description", + "deselect-all": "Désélectionner tout", + "deselect-all-objects": "Désélectionnez tous les nœuds et toutes les connexions", + "details": "Détails", + "directive-is-not-loaded": "La directive de configuration définie '{{directiveName}} n'est pas disponible.", + "events": "Événements", + "help": "Aide", + "invalid-target-rulechain": "Impossible de résoudre la chaîne de règles cible!", + "link": "Lien", + "link-details": "Détails du lien du noeud de la règle", + "link-label": "Étiquette du lien", + "link-label-required": "L'étiquette du lien est obligatoire", + "link-labels": "Étiquettes de lien", + "link-labels-required": "Les étiquettes de lien sont obligatoires", + "message": "Message", + "message-type": "Type de message", + "message-type-required": "Le type de message est obligatoire", + "metadata": "Métadonnées", + "metadata-required": "Les entrées de métadonnées ne peuvent pas être vides.", + "name": "Nom", + "name-required": "Le nom est requis.", + "no-link-label-matching": "'{{label}}' introuvable.", + "no-link-labels-found": "Aucune étiquette de lien trouvée", + "open-node-library": "Ouvrir la bibliothèque de noeud", + "output": "Output", + "rulenode-details": "Détails du noeud de la règle", + "search": "Recherche de noeuds", + "select-all": "Tout sélectionner", + "select-all-objects": "Sélectionnez tous les noeuds et connexions", + "select-message-type": "Sélectionner le type de message", + "test": "Test", + "test-script-function": "Tester le script", + "type": "Type", + "type-action": "Action", + "type-action-details": "Effectuer une action spéciale", + "type-enrichment": "Enrichissement", + "type-enrichment-details": "Ajouter des informations supplémentaires dans les métadonnées de message", + "type-external": "Externe", + "type-external-details": "Interagit avec le système externe", + "type-filter": "Filtre", + "type-filter-details": "Filtrer les messages entrants avec des conditions configurées", + "type-input": "Input", + "type-input-details": "Entrée logique de la chaîne de règles, transmet les messages entrants au prochain nœud de règle associé", + "type-rule-chain": "Chaîne de règles", + "type-rule-chain-details": "Transmet les messages entrants à la chaîne de règles spécifiée", + "type-transformation": "Transformation", + "type-transformation-details": "Modifier le payload du message et les métadonnées ", + "type-unknown": "Inconnu", + "type-unknown-details": "Noeud de règle non résolu", + "ui-resources-load-error": "Impossible de charger les ressources de configuration de l'interface utilisateur." + }, + "tenant": { + "add": "Ajouter un Tenant", + "add-tenant-text": "Ajouter un nouveau Tenant", + "admins": "Admins", + "copyId": "Copier l'Id du Tenant", + "delete": "Supprimer le Tenant", + "delete-tenant-text": "Attention, après la confirmation, le Tenant et toutes les données associées deviendront irrécupérables.", + "delete-tenant-title": "Etes-vous sûr de vouloir supprimer le tenant '{{tenantTitle}}'?", + "delete-tenants-action-title": "Supprimer {count, plural, 1 {1 tenant} other {# tenants}}", + "delete-tenants-text": "Attention, après la confirmation, tous les Tenants sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-tenants-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 tenant} other {# tenants}}?", + "description": "Description", + "details": "Détails", + "events": "Événements", + "idCopiedMessage": "L'Id du Tenant a été copié dans le Presse-papiers", + "manage-tenant-admins": "Gérer les administrateurs du Tenant", + "management": "Gestion des Tenants", + "no-tenants-matching": "Aucun Tenant correspondant à {{entity}} n'a été trouvé. ", + "no-tenants-text": "Aucun Tenant trouvé", + "select-tenant": "Sélectionner un Tenant", + "tenant": "Tenant", + "tenant-details": "Détails du Tenant", + "tenant-required": "Tenant requis", + "tenants": "Tenants", + "title": "Titre", + "title-required": "Le titre est requis." + }, + "timeinterval": { + "advanced": "Avancé", + "days": "Jours", + "days-interval": "{days, plural, 1 {1 jour} other {# jours}}", + "hours": "Heures", + "hours-interval": "{hours, plural, 1 {1 heure} other {# heures}}", + "minutes": "Minutes", + "minutes-interval": "{minutes, plural, 1 {1 minute} other {# minutes}}", + "seconds": "Secondes", + "seconds-interval": "{seconds, plural, 1 {1 seconde} other {# secondes}}" + }, + "timewindow": { + "date-range": "Plage de dates", + "days": "{days, plural, 1 {jour} other {# jours}}", + "edit": "Modifier timewindow", + "history": "Historique", + "hours": "{hours, plural, 0 {heure} 1 {1 heure} other {# heures}}", + "last": "Dernier", + "last-prefix": "dernier", + "minutes": "{minutes, plural, 0 {minute} 1 {1 minute} other {# minutes}}", + "period": "de {{startTime}} à {{endTime}}", + "realtime": "Temps réel", + "seconds": "{seconds, plural, 0 {second} 1 {1 second} other {# seconds}}", + "time-period": "Période" + }, + "user": { + "activation-email-sent-message": "L'e-mail d'activation a été envoyé avec succès!", + "activation-link": "Lien d'activation utilisateur", + "activation-link-copied-message": "le lien d'activation de l'utilisateur a été copié dans le presse-papier", + "activation-link-text": "Pour activer l'utilisateur, utilisez le lien d'activation suivant: ", + "activation-method": "Méthode d'activation", + "add": "Ajouter un utilisateur", + "add-user-text": "Ajouter un nouvel utilisateur", + "always-fullscreen": "Toujours en plein écran", + "anonymous": "Anonyme", + "copy-activation-link": "Copier le lien d'activation", + "customer": "Client", + "customer-users": "Utilisateurs du client", + "default-dashboard": "Tableau de bord par défaut", + "delete": "Supprimer l'utilisateur", + "delete-user-text": "Attention, après la confirmation, l'utilisateur et toutes les données associées deviendront irrécupérables.", + "delete-user-title": "Etes-vous sûr de vouloir supprimer l'utilisateur '{{userEmail}}'?", + "delete-users-action-title": "Supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs}}", + "delete-users-text": "Attention, après la confirmation, tous les utilisateurs sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-users-title": "Etes-vous sûr de vouloir supprimer {count, plural, 1 {1 utilisateur} other {# utilisateurs}}?", + "description": "Description", + "details": "Détails", + "display-activation-link": "Afficher le lien d'activation", + "email": "Email", + "email-required": "Email est requis.", + "first-name": "Prénom", + "invalid-email-format": "Format de courrier électronique non valide", + "last-name": "Nom de famille", + "no-users-matching": "Aucun utilisateur correspondant à '{{entity}}' n'a été trouvé.", + "no-users-text": "Aucun utilisateur trouvé", + "resend-activation": "Renvoyer l'activation", + "select-user": "Sélectionner l'utilisateur", + "send-activation-mail": "Envoyer un mail d'activation", + "sys-admin": "Administrateur du système", + "tenant-admin": "Administrateur du Tenant", + "tenant-admins": "administrateurs du Tenant", + "user": "utilisateur", + "user-details": "Détails de l'utilisateur", + "user-required": "L'utilisateur est requis", + "users": "Utilisateurs" + }, + "value": { + "boolean": "booléen", + "boolean-value": "Valeur booléenne", + "double": "Double", + "double-value": "Valeur double", + "false": "Faux", + "integer": "Entier", + "integer-value": "Valeur entière", + "invalid-integer-value": "Valeur entière invalide", + "long": "Long", + "string": "String", + "string-value": "Valeur String", + "true": "Vrai", + "type": "Type de valeur" + }, + "widget": { + "add": "Ajouter un widget", + "add-resource": "Ajouter une ressource", + "add-widget-type": "Ajouter un nouveau type de widget", + "alarm": "Widget d'alarme", + "css": "CSS", + "datakey-settings-schema": "Schéma des paramètres de Data key", + "edit": "Modifier le widget", + "editor": " Editeur de widget", + "export": "Exporter widget", + "html": "HTML", + "javascript": "Javascript", + "latest-values": "Dernières valeurs", + "management": "Gestion des widgets", + "missing-widget-title-error": "Le titre du widget doit être spécifié!", + "no-data-found": "Aucune donnée trouvée", + "remove": "Supprimer le widget", + "remove-resource": "Supprimer une ressource", + "remove-widget-text": "Après la confirmation, le widget et toutes les données associées deviendront irrécupérables.", + "remove-widget-title": "Êtes-vous sûr de vouloir supprimer le widget '{{widgetTitle}}'?", + "remove-widget-type": "Supprimer le type de widget", + "remove-widget-type-text": "Après la confirmation, le type de widget et toutes les données associées deviendront irrécupérables.", + "remove-widget-type-title": "Êtes-vous sûr de vouloir supprimer le type de widget '{{widgetName}}'?", + "resource-url": "URL JavaScript / CSS", + "resources": "Ressources", + "rpc": "Widget de contrôle", + "run": "Exécuter un widget", + "save": "Enregistrer le widget", + "save-widget-type-as": "Enregistrer le type de widget sous", + "save-widget-type-as-text": "Veuillez saisir un nouveau titre de widget et / ou sélectionner un ensemble de widgets cibles", + "saveAs": "Enregistrer le widget sous", + "search-data": "Rechercher des données", + "select-widget-type": "Sélectionnez le type de widget", + "select-widgets-bundle": "Sélectionner un ensemble de widgets", + "settings-schema": "Schéma des paramètres", + "static": "Widget statique", + "tidy": "Tidy", + "timeseries": "Séries chronologiques", + "title": "Titre du widget", + "title-required": "Le titre du widget est requis.", + "toggle-fullscreen": "Basculer le mode plein écran", + "type": "Type de widget", + "unable-to-save-widget-error": "Impossible de sauvegarder le widget! Le widget a des erreurs!", + "undo": "Annuler les modifications du widget", + "widget-bundle": "Ensemble de widget", + "widget-library": "Bibliothèque de widgets", + "widget-saved": "Widget enregistré", + "widget-template-load-failed-error": "Impossible de charger le modèle de widget!", + "widget-type-load-error": "Le widget n'a pas été chargé à cause des erreurs suivantes:", + "widget-type-load-failed-error": "Impossible de charger le type de widget!", + "widget-type-not-found": "Problème de chargement de la configuration du widget.
Le type de widget associé a probablement été supprimé." + }, + "widget-action": { + "custom": "Action personnalisée", + "header-button": "Bouton d'en-tête de widget", + "open-dashboard": "Naviguer vers un autre tableau de bord", + "open-dashboard-state": "Naviguer vers un nouvel état du tableau de bord", + "open-right-layout": "Ouvrir la disposition du tableau de bord droite (vue mobile)", + "set-entity-from-widget": "Définir l'entité à partir du widget", + "target-dashboard": "Tableau de bord cible", + "target-dashboard-state": "Etat du tableau de bord cible", + "target-dashboard-state-required": "L'état du tableau de bord cible est requis", + "update-dashboard-state": "Mettre à jour l'état actuel du tableau de bord" + }, + "widget-config": { + "action": "Action", + "action-icon": "Icône", + "action-name": "Nom", + "action-name-not-unique": "Une autre action portant le même nom existe déjà.
Le nom de l'action doit être unique dans la même source d'action.", + "action-name-required": "Le nom de l'action est requis", + "action-source": "Source de l'action", + "action-source-required": "Une source d'action est requise.", + "action-type": "Type", + "action-type-required": "Le type d'action est requis.", + "actions": "Actions", + "add-action": "Ajouter une action", + "add-datasource": "Ajouter une source de données", + "advanced": "Avancé", + "alarm-source": "Source d'alarme", + "background-color": "couleur de fond", + "data": "Données", + "datasource-parameters": "Paramètres", + "datasource-type": "Type", + "datasources": "Sources de données", + "decimals": "Nombre de chiffres après virgule flottante", + "delete-action": "Supprimer l'action", + "delete-action-text": "Etes-vous sûr de vouloir supprimer l'action du widget nommé '{{actionName}}'?", + "delete-action-title": "Supprimer l'action du widget", + "display-legend": "Afficher la légende", + "display-title": "Afficher le titre", + "drop-shadow": "Ombre portée", + "edit-action": "Modifier l'action", + "enable-fullscreen": "Activer le plein écran", + "general-settings": "Paramètres généraux", + "height": "Hauteur", + "margin": "Marge", + "maximum-datasources": "Maximum {count, plural, 1 {1 datasource est autorisé.} other {# datasources sont autorisés}}", + "mobile-mode-settings": "Paramètres du mode mobile", + "order": "Ordre", + "padding": "Padding", + "remove-datasource": "Supprimer la source de données", + "search-actions": "Recherche d'actions", + "settings": "Paramètres", + "target-device": "Dispositif cible", + "text-color": "Couleur du texte", + "timewindow": "Fenêtre de temps", + "title": "Titre", + "title-style": "Style de titre", + "units": "Symbole spécial à afficher à côté de la valeur", + "use-dashboard-timewindow": "Utiliser la fenêtre de temps du tableau de bord", + "widget-style": "Style du widget" + }, + "widget-type": { + "create-new-widget-type": "Créer un nouveau type de widget", + "export": "Exporter le type de widget", + "export-failed-error": "Impossible d'exporter le type de widget: {{error}}", + "import": "Importer le type de widget", + "invalid-widget-type-file-error": "Impossible d'importer le type de widget: structure de données de type widget invalide.", + "widget-type-file": "Fichier de type Widget" + }, + "widgets-bundle": { + "add": "Ajouter un groupe de widgets", + "add-widgets-bundle-text": "Ajouter un nouveau groupe de widgets", + "create-new-widgets-bundle": "Créer un nouveau groupe de widgets", + "current": "Groupe actuel", + "delete": "Supprimer le groupe de widgets", + "delete-widgets-bundle-text": "Attention, après la confirmation, le groupe de widgets et toutes les données associées deviendront irrécupérables.", + "delete-widgets-bundle-title": "Êtes-vous sûr de vouloir supprimer le groupe de widgets '{{widgetsBundleTitle}}'?", + "delete-widgets-bundles-action-title": "Supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets}}", + "delete-widgets-bundles-text": "Attention, après la confirmation, tous les groupes de widgets sélectionnés seront supprimés et toutes les données associées deviendront irrécupérables.", + "delete-widgets-bundles-title": "Voulez-vous vraiment supprimer {count, plural, 1 {1 groupe de widgets} other {# groupes de widgets}}?", + "details": "Détails", + "empty": "Le groupe de widgets est vide", + "export": "Exporter le groupe de widgets", + "export-failed-error": "Impossible d'exporter le groupe de widgets: {{error}}", + "import": "Importer un groupe de widgets", + "invalid-widgets-bundle-file-error": "Impossible d'importer un groupe de widgets: structure de données du groupe de widgets non valides.", + "no-widgets-bundles-matching": "Aucun groupe de widgets correspondant à {{widgetsBundle}} n'a été trouvé.", + "no-widgets-bundles-text": "Aucun groupe de widgets trouvé", + "system": "Système", + "title": "Titre", + "title-required": "Le titre est requis.", + "widgets-bundle-details": "Détails des groupes de widgets", + "widgets-bundle-file": "Fichier de groupe de widgets", + "widgets-bundle-required": "Un groupe de widgets est requis.", + "widgets-bundles": "Groupes de widgets" + } } diff --git a/ui/src/app/locale/locale.constant-it_IT.json b/ui/src/app/locale/locale.constant-it_IT.json index fcdd7453ce..49f9b4880d 100644 --- a/ui/src/app/locale/locale.constant-it_IT.json +++ b/ui/src/app/locale/locale.constant-it_IT.json @@ -528,13 +528,13 @@ "decimals": "Numero cifre decimali", "data-generation-func": "Funzione generazione dati", "use-data-post-processing-func": "Use data post-processing function", - "configuration": "Data key configuration", + "configuration": "Configurazione data key", "timeseries": "Serie temporali", "attributes": "Attributi", "alarm": "Campi allarme", "timeseries-required": "Entity timeseries are required.", "timeseries-or-attributes-required": "Entity timeseries/attributes are required.", - "maximum-timeseries-or-attributes": "Maximum { count, plural, 1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "maximum-timeseries-or-attributes": "Massimo { count, plural, 1 {1 serie temporale/attributo consentito.} other {# serie temporali/attributi consentiti.} }", "alarm-fields-required": "Campi allarme obbligatori.", "function-types": "Tipi funzione", "function-types-required": "Tipi funzione obbligatorio.", @@ -759,7 +759,7 @@ "entity": "Entità", "message-id": "Id Messaggio", "message-type": "Tipo Messaggio", - "data-type": "Data Type", + "data-type": "Tipo di dato", "relation-type": "Tipo di relazione", "metadata": "Metadati", "data": "Dati", @@ -807,7 +807,7 @@ "add-map": "Add mapping element", "timeseries": "Serie temporali", "add-timeseries": "Add timeseries", - "field-required": "Field is required", + "field-required": "Campo obbligatorio", "brokers": "Broker", "add-broker": "Aggiungi broker", "host": "Host", @@ -855,15 +855,15 @@ "add-attribute-request": "Add attribute request", "attribute-updates": "Attribute updates", "add-attribute-update": "Add attribute update", - "server-side-rpc": "Server side RPC", + "server-side-rpc": "RPC lato server", "add-server-side-rpc-request": "Add server-side RPC request", - "device-name-filter": "Device name filter", + "device-name-filter": "Filtro nome dispositivo", "attribute-filter": "Filtro attributo", "method-filter": "Filtro metodo", "request-topic-expression": "Request topic expression", "response-timeout": "Response timeout in milliseconds", "topic-expression": "Topic expression", - "client-scope": "Client scope", + "client-scope": "Visibilità client", "add-device": "Aggiungi dispositivo", "opc-server": "Server", "opc-add-server": "Aggiungi server", @@ -892,19 +892,19 @@ "modbus-baudrate": "Baud rate", "modbus-databits": "Data bits", "modbus-stopbits": "Stop bits", - "modbus-databits-range": "Data bits should be in a range from 7 to 8.", - "modbus-stopbits-range": "Stop bits should be in a range from 1 to 2.", - "modbus-unit-id": "Unit ID", - "modbus-unit-id-range": "Unit ID should be in a range from 1 to 247.", + "modbus-databits-range": "Data bits deve essere compreso nell'intervallo 7-8.", + "modbus-stopbits-range": "Stop bits deve essere compreso nell'intervallo 1-2.", + "modbus-unit-id": "ID unità", + "modbus-unit-id-range": "ID unità deve essere compreso nell'intervallo 1-247.", "modbus-device-name": "Nome dispositivo", "modbus-poll-period": "Intervallo di polling (ms)", - "modbus-attributes-poll-period": "Attributes poll period (ms)", - "modbus-timeseries-poll-period": "Timeseries poll period (ms)", + "modbus-attributes-poll-period": "Intervallo di polling degli attributi (ms)", + "modbus-timeseries-poll-period": "Intervallo di polling delle serie temporali (ms)", "modbus-poll-period-range": "L'intervallo di polling deve essere un valore positivo.", "modbus-tag": "Tag", "modbus-function": "Funzione", "modbus-register-address": "Indirizzo registro", - "modbus-register-address-range": "L'indirizzo del registro deve essere compreso tra 0 e 65535.", + "modbus-register-address-range": "Indirizzo registro deve essere compreso tra 0 e 65535.", "modbus-register-bit-index": "Bit index", "modbus-register-bit-index-range": "Bit index should be in a range from 0 to 15.", "modbus-register-count": "Register count", @@ -950,7 +950,7 @@ "scroll-to-top": "Scorri verso l'alto" }, "help": { - "goto-help-page": "Go to help page" + "goto-help-page": "Vai all'help" }, "home": { "home": "Home", @@ -975,9 +975,9 @@ "key-val": { "key": "Chiave", "value": "Valore", - "remove-entry": "Remove entry", - "add-entry": "Add entry", - "no-data": "No entries" + "remove-entry": "Rimuovi voce", + "add-entry": "Aggiungi voce", + "no-data": "Nessuna voce" }, "layout": { "layout": "Layout", @@ -1002,8 +1002,8 @@ }, "login": { "login": "Login", - "request-password-reset": "Request Password Reset", - "reset-password": "Azzera Password", + "request-password-reset": "Richiesta reset password", + "reset-password": "Reset Password", "create-password": "Crea Password", "passwords-mismatch-error": "Le password inserite devono corrispondere!", "password-again": "Ripeti Password", @@ -1014,7 +1014,7 @@ "password-reset": "Password reset", "new-password": "Nuova password", "new-password-again": "Ripeti nuova password", - "password-link-sent-message": "Link azzeramento password inviato con successo!", + "password-link-sent-message": "Link reset password inviato con successo!", "email": "Email" }, "position": { @@ -1029,7 +1029,7 @@ "current-password": "Password attuale" }, "relation": { - "relations": "Relations", + "relations": "Relazioni", "direction": "Direzione", "search-direction": { "FROM": "Da", @@ -1039,9 +1039,9 @@ "FROM": "da", "TO": "a" }, - "from-relations": "Outbound relations", - "to-relations": "Inbound relations", - "selected-relations": "{ count, plural, 1 {1 relation} other {# relations} } selected", + "from-relations": "Relazioni in uscita", + "to-relations": "Relazioni in ingresso", + "selected-relations": "{ count, plural, 1 {1 relazione selezionata} other {# relazioni selezionate} }", "type": "Tipo", "to-entity-type": "A tipo entità", "to-entity-name": "A nome entità", @@ -1049,26 +1049,26 @@ "from-entity-name": "Da nome entità", "to-entity": "A entità", "from-entity": "Da entità", - "delete": "Delete relation", - "relation-type": "Relation type", - "relation-type-required": "Relation type is required.", + "delete": "Elimina relazione", + "relation-type": "Tipo di relazione", + "relation-type-required": "Tipo di relazione obbligatorio.", "any-relation-type": "Ogni tipo", - "add": "Add relation", - "edit": "Edit relation", - "delete-to-relation-title": "Are you sure you want to delete relation to the entity '{{entityName}}'?", + "add": "Aggiungi relazione", + "edit": "Modifica relazione", + "delete-to-relation-title": "Sei sicuro di voler eliminare la relazione con l'entità '{{entityName}}'?", "delete-to-relation-text": "Attenzione, dopo la conferma l'entità '{{entityName}}' sarà scollegata dall'entità corrente.", - "delete-to-relations-title": "Are you sure you want to delete { count, plural, 1 {1 relation} other {# relations} }?", - "delete-to-relations-text": "Be careful, after the confirmation all selected relations will be removed and corresponding entities will be unrelated from the current entity.", - "delete-from-relation-title": "Are you sure you want to delete relation from the entity '{{entityName}}'?", - "delete-from-relation-text": "Be careful, after the confirmation current entity will be unrelated from the entity '{{entityName}}'.", - "delete-from-relations-title": "Are you sure you want to delete { count, plural, 1 {1 relation} other {# relations} }?", - "delete-from-relations-text": "Be careful, after the confirmation all selected relations will be removed and current entity will be unrelated from the corresponding entities.", - "remove-relation-filter": "Remove relation filter", - "add-relation-filter": "Add relation filter", - "any-relation": "Any relation", - "relation-filters": "Relation filters", - "additional-info": "Additional info (JSON)", - "invalid-additional-info": "Unable to parse additional info json." + "delete-to-relations-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 relazione} other {# relazioni} }?", + "delete-to-relations-text": "Attenzione, dopo la conferma tutte le relazioni selezionate saranno rimosse e le corrispondenti entità scollegate da quella corrente.", + "delete-from-relation-title": "Sei sicuro di voler eliminare la relazione dall'entità '{{entityName}}'?", + "delete-from-relation-text": "Attenzione, dopo la conferma l'entità corrente sarà scollegata dall'entità '{{entityName}}'.", + "delete-from-relations-title": "Sei sicuro di voler eliminare { count, plural, 1 {1 relazione} other {# relazioni} }?", + "delete-from-relations-text": "Attenzione, dopo la conferma tutte le relazioni selezionate saranno rimosse e l'entità corrente scollegata dalle corrispondenti entità.", + "remove-relation-filter": "Rimuovi filtro relazioni", + "add-relation-filter": "Aggiungi filtro relazioni", + "any-relation": "Qualsiasi relazione", + "relation-filters": "Filtri relazioni", + "additional-info": "Informazioni aggiuntive (JSON)", + "invalid-additional-info": "Impossibile analizzare le informazioni aggiuntive in JSON." }, "rulechain": { "rulechain": "Rule chain", @@ -1441,7 +1441,8 @@ "it_IT": "Italiano", "ru_RU": "Russo", "es_ES": "Spagnolo", - "ja_JA": "Giapponese" + "ja_JA": "Giapponese", + "tr_TR": "Turco" } } } diff --git a/ui/src/app/locale/locale.constant-ja_JA.json b/ui/src/app/locale/locale.constant-ja_JA.json index 77765efddb..2ab2bcb909 100644 --- a/ui/src/app/locale/locale.constant-ja_JA.json +++ b/ui/src/app/locale/locale.constant-ja_JA.json @@ -1457,7 +1457,8 @@ "zh_CN": "中国語", "ru_RU": "ロシア", "es_ES": "スペイン語", - "ja_JA": "日本語" + "ja_JA": "日本語", + "tr_TR": "トルコ語" } } } diff --git a/ui/src/app/locale/locale.constant-ko_KR.json b/ui/src/app/locale/locale.constant-ko_KR.json index 6027ef874f..62c0054d53 100644 --- a/ui/src/app/locale/locale.constant-ko_KR.json +++ b/ui/src/app/locale/locale.constant-ko_KR.json @@ -1335,7 +1335,8 @@ "ru_RU": "러시아어", "es_ES": "스페인어", "it_IT": "이탈리아 사람", - "ja_JA": "일본어" + "ja_JA": "일본어", + "tr_TR": "터키어" } } } diff --git a/ui/src/app/locale/locale.constant-ru_RU.json b/ui/src/app/locale/locale.constant-ru_RU.json index 9d2c7130e1..eb9996d274 100644 --- a/ui/src/app/locale/locale.constant-ru_RU.json +++ b/ui/src/app/locale/locale.constant-ru_RU.json @@ -1360,7 +1360,8 @@ "es_ES": "Испанский", "it_IT": "Итальянский", "ru_RU": "Русский", - "ja_JA": "Японский" + "ja_JA": "Японский", + "tr_TR": "Турецкий" } } diff --git a/ui/src/app/locale/locale.constant-tr_TR.json b/ui/src/app/locale/locale.constant-tr_TR.json new file mode 100644 index 0000000000..54176d83a9 --- /dev/null +++ b/ui/src/app/locale/locale.constant-tr_TR.json @@ -0,0 +1,1545 @@ +{ + "access": { + "unauthorized": "Yetkisiz", + "unauthorized-access": "Yetkisiz Erişim", + "unauthorized-access-text": "Bu kaynağa erişmek için giriş yapmalısınız!", + "access-forbidden": "Erişim Yasaklandı", + "access-forbidden-text": "Bu konuma erişim haklarınız yok!
Bu yere hala erişmek istiyorsanız farklı kullanıcılarla oturum açmayı deneyin.", + "refresh-token-expired": "Oturum süresi doldu", + "refresh-token-failed": "Oturum yenilenemiyor" + }, + "action": { + "activate": "Etkinleştir", + "suspend": "Askıya al", + "save": "Kaydet", + "saveAs": "Farklı Kaydet", + "cancel": "İptal", + "ok": "Tamam", + "delete": "Sil", + "add": "Ekle", + "yes": "Evet", + "no": "Hayır", + "update": "Güncelle", + "remove": "Kaldır", + "search": "Ara", + "clear-search": "Aramayı Temizle", + "assign": "Ata", + "unassign": "Atamayı kaldır", + "share": "Paylaş", + "make-private": "Özel yap", + "apply": "Uygula", + "apply-changes": "Değişiklikleri Uygula", + "edit-mode": "Düzenleme Modu", + "enter-edit-mode": "Düzenleme moduna gir", + "decline-changes": "Değişiklikleri reddet", + "close": "Kapat", + "back": "Geri", + "run": "Çalıştır", + "sign-in": "Giriş yap!", + "edit": "Düzenle", + "view": "Görüntüle", + "create": "Oluştur", + "drag": "Sürükle", + "refresh": "Yenile", + "undo": "Geri al", + "copy": "Kopyala", + "paste": "Yapıştır", + "copy-reference": "Referansı kopyala", + "paste-reference": "Referansı yapıştır", + "import": "İçe aktar", + "export": "Dışa aktar", + "share-via": "{{provider}} ile paylaş" + }, + "aggregation": { + "aggregation": "Toplama", + "function": "Veri toplama işlevi", + "limit": "Maksimum değerler", + "group-interval": "Gruplama aralığı", + "min": "Min", + "max": "Maks", + "avg": "Ortalama", + "sum": "Toplam", + "count": "Sayı", + "none": "Yok" + }, + "admin": { + "general": "Genel", + "general-settings": "Genel Ayarlar", + "outgoing-mail": "Giden Posta", + "outgoing-mail-settings": "Giden Posta Ayarları", + "system-settings": "Sistem Ayarları", + "test-mail-sent": "Test e-postası başarıyla gönderildi!", + "base-url": "Temel URL", + "base-url-required": "Temel URL gerekli.", + "mail-from": "Gönderen Kişi", + "mail-from-required": "Gönderen Kişi gerekli.", + "smtp-protocol": "SMTP protokolü", + "smtp-host": "SMTP sunucusu", + "smtp-host-required": "SMTP sunucusu gerekli.", + "smtp-port": "SMTP portu", + "smtp-port-required": "Bir SMTP portu sağlamalısınız.", + "smtp-port-invalid": "Bu geçerli bir smtp portu gibi görünmüyor.", + "timeout-msec": "Zaman aşımı (milisaniye)", + "timeout-required": "Zaman aşımı değeri gerekli.", + "timeout-invalid": "Bu geçerli bir zaman aşımı gibi görünmüyor.", + "enable-tls": "TLS'i etkinleştir.", + "send-test-mail": "Test e-postası gönder" + }, + "alarm": { + "alarm": "Alarm", + "alarms": "Alarmlar", + "select-alarm": "Alarm seç", + "no-alarms-matching": "'{{entity}}' ile eşleşen alarm bulunamadı.", + "alarm-required": "Alarm gerekli", + "alarm-status": "Alarm durumu", + "search-status": { + "ANY": "Herhangi biri", + "ACTIVE": "Aktif", + "CLEARED": "Temizlendi", + "ACK": "Onaylandı", + "UNACK": "Onaylanmadı" + }, + "display-status": { + "ACTIVE_UNACK": "Aktif Onaylanmadı", + "ACTIVE_ACK": "Aktif Onaylandı", + "CLEARED_UNACK": "Temizlendi Onaylanmadı", + "CLEARED_ACK": "Temizlendi Onaylandı" + }, + "no-alarms-prompt": "Alarm bulunamadı", + "created-time": "Oluşma zamanı", + "type": "Tip", + "severity": "Şiddet", + "originator": "Kaynak", + "originator-type": "Kaynak tipi", + "details": "Detaylar", + "status": "Durum", + "alarm-details": "Alarm detayları", + "start-time": "Başlangıç zamanı", + "end-time": "Bitiş zamanı", + "ack-time": "Onaylanma zamanı", + "clear-time": "Temizlenme zamanı", + "severity-critical": "Kritik", + "severity-major": "Birincil", + "severity-minor": "İkincil", + "severity-warning": "Uyarı", + "severity-indeterminate": "Belirsiz", + "acknowledge": "Onayla", + "clear": "Temizle", + "search": "Alarm ara", + "selected-alarms": "{ count, plural, 1 {1 alarm} other {# alarm} } seçildi", + "no-data": "Görüntülenecek veri bulunmuyor", + "polling-interval": "Alarm yoklama aralığı (saniye)", + "polling-interval-required": "Alarm yoklama aralığı gerekli.", + "min-polling-interval-message": "Alarm yoklama aralığı en az 1 saniye olmalıdır.", + "aknowledge-alarms-title": "{ count, plural, 1 {1 alarmı} other {# alarmı} } onayla", + "aknowledge-alarms-text": "{ count, plural, 1 {1 alarmı} other {# alarmı} } onaylamak istediğinize emin misiniz?", + "clear-alarms-title": "{ count, plural, 1 {1 alarmı} other {# alarmı} } temizle", + "clear-alarms-text": "{ count, plural, 1 {1 alarmı} other {# alarmı} } temizlemek istediğinize emin misiniz?" + }, + "alias": { + "add": "Kısa ad ekle", + "edit": "Kısa ad düzenle", + "name": "Kısa ad", + "name-required": "Kısa ad gerekli", + "duplicate-alias": "Aynı kısa ad daha önce kullanılmış.", + "filter-type-single-entity": "Tek öğe", + "filter-type-entity-list": "Öğe listesi", + "filter-type-entity-name": "Öğe adı", + "filter-type-state-entity": "Kontrol panelinden öğe", + "filter-type-state-entity-description": "Kontrol tablosu durum parametrelerinden alınan öğeler", + "filter-type-asset-type": "Varlık türü", + "filter-type-asset-type-description": "'{{assetType}}' türünde varlıklar", + "filter-type-asset-type-and-name-description": "Adı '{{prefix}}' ile başlayan '{{assetType}}' türünde varlıklar", + "filter-type-device-type": "Aygıt türü", + "filter-type-device-type-description": "'{{deviceType}}' türünde aygıtlar", + "filter-type-device-type-and-name-description": "Adı '{{prefix}}' ile başlayan'{{deviceType}}' türünde aygıtlar", + "filter-type-relations-query": "İlişkiler sorgusu", + "filter-type-relations-query-description": "{{relationType}} türünde ilişkili olan varlıklar: {{entities}}. {{direction}}: {{rootEntity}}", + "filter-type-asset-search-query": "Varlık arama sorgusu", + "filter-type-asset-search-query-description": "{{relationType}} türünde ilişkisi olan varlıklar {{assetTypes}}. {{direction}}: {{rootEntity}}", + "filter-type-device-search-query": "Aygıt arama sorgusu", + "filter-type-device-search-query-description": "{{relationType}} türünde ilişkisi olan aygıt tipleri {{deviceTypes}}. {{direction}}: {{rootEntity}}", + "entity-filter": "Öğe filtresi", + "resolve-multiple": "Çoklu öğe olarak çözümle", + "filter-type": "Filtre tipi", + "filter-type-required": "Filtre tipi gerekli.", + "entity-filter-no-entity-matched": "Belirlenen filtre ile eşleşen bir öğe bulunamadı.", + "no-entity-filter-specified": "Hiçbir öğe filtresi belirtilmedi", + "root-state-entity": "Kontrol panelini kök olarak kullan", + "root-entity": "Kök öğe", + "state-entity-parameter-name": "Durum varlığı parametre adı", + "default-state-entity": "Varsayılan durum öğesi", + "default-entity-parameter-name": "Varsayılan", + "max-relation-level": "Maksimum ilişki düzeyi", + "unlimited-level": "Sınırsız seviye", + "state-entity": "Kontrol paneli öğesi", + "all-entities": "Tüm öğeler", + "any-relation": "Herhangi biri" + }, + "asset": { + "asset": "Varlık", + "assets": "Varlıklar", + "management": "Varlık Yönetimi", + "view-assets": "Varlıkları Görüntüle", + "add": "Varlık ekle", + "assign-to-customer": "Kullanıcı grubuna ata", + "assign-asset-to-customer": "Varlıkları Kullanıcı Grubuna Ata", + "assign-asset-to-customer-text": "Lütfen kullanıcı grubuna atanacak varlıkları seçin", + "no-assets-text": "Varlık bulunamadı", + "assign-to-customer-text": "Lütfen varlıkları atamak için kullanıcı grubu seçin", + "public": "Açık", + "assignedToCustomer": "Kullanıcı grubuna atandı", + "make-public": "Varlığı açık hale getir", + "make-private": "Varlığı özel hale getir", + "unassign-from-customer": "Kullanıcı grubundan atamayı kaldır", + "delete": "Varlığı sil", + "asset-public": "Varlık açık halde", + "asset-type": "Varlık türü", + "asset-type-required": "Varlık türü gerekli.", + "select-asset-type": "Varlık türü seçin", + "enter-asset-type": "Varlık türü girin", + "any-asset": "Herhangi bir varlık", + "no-asset-types-matching": "'{{entitySubtype}}' ile eşleşen varlık bulunamadı.", + "asset-type-list-empty": "Herhangi bir varlık türü bulunamadı.", + "asset-types": "Varlık türleri", + "name": "İsim", + "name-required": "İsim gerekli.", + "description": "Açıklama", + "type": "Tür", + "type-required": "Tür gerekli.", + "details": "Detaylar", + "events": "Olaylar", + "add-asset-text": "Yeni varlık ekle", + "asset-details": "Varlık detayları", + "assign-assets": "Varlıkları ata", + "assign-assets-text": "{ count, plural, 1 {1 varlığı} other {# varlığı} } kullanıcı grubuna ata", + "delete-assets": "Varlıkları sil", + "unassign-assets": "Varlıkların atamalarını kaldır", + "unassign-assets-action-title": "{ count, plural, 1 {1 varlığın} other {# varlığın} } atamalarını kullanıcı grubundan kaldır", + "assign-new-asset": "Yeni varlık ata", + "delete-asset-title": "'{{assetName}}' isimli varlığı silmek istediğinize emin misiniz?", + "delete-asset-text": "UYARI: Onaylandıktan sonra varlık ve ilgili tüm veriler geri yüklenemeyecek şekilde silinecek.", + "delete-assets-title": "{ count, plural, 1 {1 varlığı} other {# varlığı} } silmek istediğinize emin misiniz?", + "delete-assets-action-title": "{ count, plural, 1 {1 varlığı} other {# varlığı} } sil", + "delete-assets-text": "UYARI: Onaylandıktan sonra tüm seçili varlıklar ver ilgili tüm veriler geri yüklenemyeck şekilde silinecek.", + "make-public-asset-title": "'{{assetName}}' isimli varlığı açık hale getirmek istediğinize emin misiniz?", + "make-public-asset-text": "Onaylandıktan sonra varlık ve ilgili veriler açık hale gelecek ve başkaları tarafından erişilebilir olacaktır.", + "make-private-asset-title": "'{{assetName}}' isimli varlığı özel hale getirmek istediğinize emin misiniz?", + "make-private-asset-text": "Onaylandıktan sonra varlık ve ilgili veriler özel hale gelecek ve başkaları tarafından erişilemez olacaktır.", + "unassign-asset-title": "'{{assetName}}' isimli varlığın atamasını kaldırmak istediğinize emin misiniz?", + "unassign-asset-text": "Onaylandıktan sonra varlığın ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez olacaktır.", + "unassign-asset": "Varlık atamasını kaldır", + "unassign-assets-title": " { count, plural, 1 {1 varlık} other {# varlık} } atamasını kaldırmak istediğinize emin misiniz?", + "unassign-assets-text": "Onaylandıktan sonra tüm seçili varlıkların ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez olacaktır.", + "copyId": "Varlık kimliğini kopyala", + "idCopiedMessage": "Varlık kimliği panoya kopyalandı", + "select-asset": "Varlık seç", + "no-assets-matching": "'{{entity}}' isimli varlık bulunamadı.", + "asset-required": "Varlık gerekli", + "name-starts-with": "... ile başlayan varlık adı" + }, + "attribute": { + "attributes": "Öznitelikler", + "latest-telemetry": "Son telemetri", + "attributes-scope": "Varlık öznitelik kapsamı", + "scope-latest-telemetry": "Son telemetri", + "scope-client": "İstemci öznitelikler", + "scope-server": "Sunucu öznitelikler", + "scope-shared": "Paylaşılan öznitelikler", + "add": "Öznitelik ekle", + "key": "Anahtar", + "last-update-time": "Son güncelleme zamanı", + "key-required": "Öznitelik anahtarı gerekli.", + "value": "Değer", + "value-required": "Öznitelik değeri gerekli.", + "delete-attributes-title": "Silmek istediğinize emin misiniz { count, plural, 1 {1 öznitelik} other {# öznitelik} }?", + "delete-attributes-text": "UYARI: Onaylandıktan sonra tüm seçili öznitelikler kaldırılacak.", + "delete-attributes": "Öznitelikleri sil", + "enter-attribute-value": "Öznitelik değeri gir", + "show-on-widget": "Göstergede göster", + "widget-mode": "Gösterge modu", + "next-widget": "Sonraki gösterge", + "prev-widget": "Önceki gösterge", + "add-to-dashboard": "Kontrol paneline ekle", + "add-widget-to-dashboard": "Göstergeyi kontrol paneline ekle", + "selected-attributes": "{ count, plural, 1 {1 öznitelik} other {# öznitelik} } seçildi", + "selected-telemetry": "{ count, plural, 1 {1 telemetri birimi} other {# telemetri birimi} } seçildi" + }, + "audit-log": { + "audit": "Log ve Hata Yönetimi", + "audit-logs": "Loglar ve Hatalar", + "timestamp": "Zaman", + "entity-type": "Kaynak", + "entity-name": "İsim", + "user": "Kullanıcı", + "type": "Tür", + "status": "Durum", + "details": "Detaylar", + "type-added": "Eklendi", + "type-deleted": "Silindi", + "type-updated": "Güncellendi", + "type-attributes-updated": "Özellikler güncellendi", + "type-attributes-deleted": "Özellikler silindi", + "type-rpc-call": "Uzaktan işlem çağrısı", + "type-credentials-updated": "Kimlik bilgileri güncellendi", + "type-assigned-to-customer": "Kullanıcı grubuna atandı", + "type-unassigned-from-customer": "Kullanıcı grubundan atama kaldırıldı", + "type-activated": "Etkinleştirildi", + "type-suspended": "Askıya alındı", + "type-credentials-read": "Kimlik bilgileri okundu", + "type-attributes-read": "Özellikler okundu", + "type-relation-add-or-update": "İlişki güncellendi", + "type-relation-delete": "İlişki silindi", + "type-relations-delete": "Tüm ilişki silindi", + "type-alarm-ack": "Kabul edilen", + "type-alarm-clear": "Temizlendi", + "status-success": "Başarılı", + "status-failure": "Başarısız", + "audit-log-details": "Log ve hata detayları", + "no-audit-logs-prompt": "Log ve hata bulunamadı", + "action-data": "Eylem verisi", + "failure-details": "Başarısız işlem detayları", + "search": "Hata ve Log Geçmişinde Ara", + "clear-search": "Aramayı temizle" + }, + "confirm-on-exit": { + "message": "Kaydedilmemiş değişiklikler var. Sayfadan ayrılmak istediğinize emin misiniz?", + "html-message": "Kaydedilmemiş değişiklikler var.
Sayfadan ayrılmak istediğinize emin misiniz?", + "title": "Kaydedilmemiş Değişiklikler" + }, + "contact": { + "country": "Ülke", + "city": "Şehir", + "state": "Eyalet / İl", + "postal-code": "Posta Kodu", + "postal-code-invalid": "Geçersiz Posta Kodu.", + "address": "Addres", + "address2": "Addres 2", + "phone": "Telefon", + "email": "E-posta", + "no-address": "Adres yok" + }, + "common": { + "username": "Kullanıcı adı", + "password": "Parola", + "enter-username": "Kullanıcı adı gir", + "enter-password": "Parola gir", + "enter-search": "Arama gir" + }, + "content-type": { + "json": "Json", + "text": "Metin", + "binary": "İkili (Base64)" + }, + "customer": { + "customer": "Kullanıcı Grubu", + "customers": "Kullanıcı Grupları", + "management": "Kullanıcı Grubu Yönetimi", + "dashboard": "Kullanıcı Grubu Kontrol Paneli", + "dashboards": "Kullanıcı Grubu Kontrol Panellleri", + "devices": "Kullanıcı Grubu Aygıtları", + "entity-views": "Müşteri Varlığı Görüntüleme Sayısı", + "assets": "Kullanıcı Grubu Varlıkları", + "public-dashboards": "Açık Kontrol Panelleri", + "public-devices": "Açık Aygıtlar", + "public-assets": "Açık Varlıklar", + "public-entity-views": "Kamu Varlık Görüntüleme Sayısı", + "add": "Kullanıcı grubu ekle", + "delete": "Kullanıcı grubunu sil", + "manage-customer-users": "Kullanıcı grubu kullanıcılarını yönet", + "manage-customer-devices": "Kullanıcı grubu aygıtlarını yönet", + "manage-customer-dashboards": "Kullanıcı grubu kontrol panellerini yönet", + "manage-public-devices": "Açık aygıtları yönet", + "manage-public-dashboards": "Açık kontrol panellerini yönet", + "manage-customer-assets": "Kullanıcı Grubu varlıklarını yönet", + "manage-public-assets": "Açık varlıkları yönet", + "add-customer-text": "Yeni Kullanıcı Grubu ekle", + "no-customers-text": "Kullanıcı Grubu bulunamadı", + "customer-details": "Kullanıcı Grubu detayları", + "delete-customer-title": "'{{customerTitle}}' isimli kullanıcı grubunu silmek istediğinize emin misiniz?", + "delete-customer-text": "UYARI: Onaylandıktan sonra kullanıcı grubu ve tüm ilişkili veriler geri yüklenemeyecek şekilde silinecek.", + "delete-customers-title": "{ count, plural, 1 {1 kullanıcı grubunu} other {# kullanıcı grubunu} } silmek istediğinize emin misiniz?", + "delete-customers-action-title": "{ count, plural, 1 {1 kullanıcı grubunu} other {# kullanıcı grubunu} } sil", + "delete-customers-text": "UYARI: Onaylandıktan sonra tüm seçili kullanıcı grupları ve ilişkili veriler geri yüklenemez şekilde silinecek.", + "manage-users": "Kullanıcıları yönet", + "manage-assets": "Varlıkları yönet", + "manage-devices": "Aygıtları yönet", + "manage-dashboards": "Kontrol panellerini yönet", + "title": "Başlık", + "title-required": "Başlık gerekli.", + "description": "Açıklama", + "details": "Detaylar", + "events": "Olaylar", + "copyId": "Kullanıcı kimliğini kopyala", + "idCopiedMessage": "Kullanıcı kimliği panoya kopyalandı", + "select-customer": "Kullanıcı grubunu seç", + "no-customers-matching": "'{{entity}}' ile eşleşen kullanıcı grubu bulunamadı.", + "customer-required": "Kullanıcı grubu gerekli", + "select-default-customer": "Varsayılan müşteriyi seç", + "default-customer": "Varsayılan müşteri", + "default-customer-required": "Kiracı düzeyinde gösterge tablosunda hata ayıklamak için varsayılan müşteri gerekiyor" + }, + "datetime": { + "date-from": "Tarihinden", + "time-from": "Saatinden", + "date-to": "Tarihine", + "time-to": "Saatine" + }, + "dashboard": { + "dashboard": "Kontrol Paneli", + "dashboards": "Kontrol Panelleri", + "management": "Kontrol Paneli Yönetimi", + "view-dashboards": "Kontrol Panellerini Görüntüle", + "add": "Kontrol Paneli Ekle", + "assign-dashboard-to-customer": "Kullanıcı Grubuna Kontrol Panel(ler)i Ata", + "assign-dashboard-to-customer-text": "Lütfen kullanıcı grubuna atanacak kontrol panellerini seçin", + "assign-to-customer-text": "Lütfen kontrol panel(ler)ini atayacak kullanıcı grubu seçin", + "assign-to-customer": "Kullanıcı grubuna ata", + "unassign-from-customer": "Kullanıcı grubundan atamayı kaldır", + "make-public": "Kontrol panelini açık hale getir", + "make-private": "Kontrol panelini özel hale getir", + "manage-assigned-customers": "Atanan müşterileri yönet", + "assigned-customers": "Atanan müşteriler", + "assign-to-customers": "Gösterge Tablosunu / Müşterilerini Müşterilere Atama", + "assign-to-customers-text": "Lütfen gösterge panosunu atamak için müşterileri seçin", + "unassign-from-customers": "Müşterilerden Gösterge Tablosunu (Notlarını) Atama", + "unassign-from-customers-text": "Lütfen gösterge tablosundan atamak için müşterileri seçin", + "no-dashboards-text": "Kontrol paneli bulunamadı", + "no-widgets": "Hiçbir gösterge yapılandırılmadı", + "add-widget": "Yeni gösterge ekle", + "title": "Başlık", + "select-widget-title": "Gösterge seç", + "select-widget-subtitle": "Kullanılabilir gösterge türleri listesi", + "delete": "Kontrol paneli sil", + "title-required": "Başlık gerekli.", + "description": "Açıklama", + "details": "Detaylar", + "dashboard-details": "Kontrol paneli detayları", + "add-dashboard-text": "Yeni kontrol paneli ekle", + "assign-dashboards": "Kontrol panelleri ata", + "assign-new-dashboard": "Yeni kontrol paneli ata", + "assign-dashboards-text": "{ count, plural, 1 {1 kontrol panelini} other {# kontrol panelini} } kullanıcı grubuna ata", + "unassign-dashboards-action-text": "Müşterilerden atama { count, plural, 1 {1 gösterge tablosu} other {# panolar}}", + "delete-dashboards": "Kontrol panellerini sil", + "unassign-dashboards": "Kontrol panellerinden atamayı kaldır", + "unassign-dashboards-action-title": "{ count, plural, 1 {1 kontrol panelinin} other {# kontrol panelinin} } atamaları kullanıcı grubundan kaldır", + "delete-dashboard-title": "'{{dashboardTitle}}' isimli kontrol panelini silmek istediğinize emin misiniz?", + "delete-dashboard-text": "UYARI: Onaylandıktan sonra kontrol paneli ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "delete-dashboards-title": "{ count, plural, 1 {1 kontrol panelini} other {# kontrol panelini} } silmek istediğinize emin misiniz?", + "delete-dashboards-action-title": "{ count, plural, 1 {1 kontrol panelini} other {# kontrol panelini} } sil", + "delete-dashboards-text": "UYARI: Onaylandıktan sonra tüm seçili kontrol panelleri ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "unassign-dashboard-title": "'{{dashboardTitle}}' isimli kontrol panelindeki atamayı kaldırmak istediğinize emin misiniz?", + "unassign-dashboard-text": "Onaylandıktan sonra kontrol panelinin ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez hale gelecektir.", + "unassign-dashboard": "Kontrol panelinin ataması kaldır", + "unassign-dashboards-title": "{count, plural, 1 {1 kontrol panelindeki} other {# kontrol panelindeki} } atamayı kaldırmak istediğinize emin misiniz?", + "unassign-dashboards-text": "Onaylandıktan {{dashboardTitle}}
açık hale getirildi ve bu bağlantıdan erişilebilir durumda", + "public-dashboard-notice": "Not: Kontrol panelinden tüm verilere erişebilmek adına ilişkili aygıtları da açık hale getirmeniz gerekmektedir.", + "make-private-dashboard-title": "'{{dashboardTitle}}' isimli kontrol panelini özel hale getirmek istediğinize emin misiniz?", + "make-private-dashboard-text": "Onaylandıktan sonra kontrol paneli özel hale getirilecek ve başkaları tarafından erişilemez olacak.", + "make-private-dashboard": "Kontrol panelini özel hale getir", + "socialshare-text": "'{{dashboardTitle}}'", + "socialshare-title": "'{{dashboardTitle}}'", + "select-dashboard": "Kontrol paneli seç", + "no-dashboards-matching": "'{{entity}}' ile eşleşen kontrol paneli bulunamadı.", + "dashboard-required": "Kontrol paneli gerekli.", + "select-existing": "Var olan bir kontrol paneli seç", + "create-new": "Yeni bir kontrol paneli oluştur", + "new-dashboard-title": "Yeni kontrol paneli başlığı", + "open-dashboard": "Kontrol panelini aç", + "set-background": "Arka plan belirle", + "background-color": "Arka plan rengi", + "background-image": "Arka plan resmi", + "background-size-mode": "Arka plan boyutu modu", + "no-image": "Hiçbir resim seçilmedi", + "drop-image": "Bir resim bırakın veya yüklenecek dosyayı seçmek için tıklayın.", + "settings": "Ayarlar", + "columns-count": "Kolon sayısı", + "columns-count-required": "Kolon sayısı gerekli.", + "min-columns-count-message": "Kolon sayısı en az 10 olabilir.", + "max-columns-count-message": "Kolon sayısı en fazla 1000 olabilir.", + "widgets-margins": "Göstergeler arasındaki aralık", + "horizontal-margin": "Yatay aralık", + "horizontal-margin-required": "Yatay aralık değeri gerekli.", + "min-horizontal-margin-message": "Yatay aralık değeri en az 0 olabilir.", + "max-horizontal-margin-message": "Yatay aralık değeri en fazla 50 olabilir.", + "vertical-margin": "Dikey aralık", + "vertical-margin-required": "Dikey aralık değeri gerekli.", + "min-vertical-margin-message": "Dikey aralık değeri en az 0 olabilir.", + "max-vertical-margin-message": "Dikey aralık değeri en fazla 50 olabilir.", + "autofill-height": "Otomatik doldurma düzeni yüksekliği", + "mobile-layout": "Mobil düzen ayarları", + "mobile-row-height": "Mobil satır yüksekliği, px", + "mobile-row-height-required": "Mobil satır yüksekliği değeri gerekli.", + "min-mobile-row-height-message": "Mobil satır yükseliği değeri en az 5 px olabilir.", + "max-mobile-row-height-message": "Mobil satır yükseliği değeri en çok 200 px olabilir.", + "display-title": "Kontrol paneli başlığını göster", + "toolbar-always-open": "Araç çubuğunu her zaman açık tut", + "title-color": "Başlık rengi", + "display-dashboards-selection": "Kontrol paneli seçimlerinş göster", + "display-entities-selection": "Varlık seçimlerini göster", + "display-dashboard-timewindow": "Zaman aralığını göster", + "display-dashboard-export": "Dışa aktar seçeneğini göster", + "import": "Kontrol panelini içe aktar", + "export": "Kontrol panelini dışa aktar", + "export-failed-error": "Kontrol paneli dışa aktarılamıyor: {{error}}", + "create-new-dashboard": "Yeni kontrol paneli oluştur", + "dashboard-file": "Kontrol paneli dosyası", + "invalid-dashboard-file-error": "Kontrol paneli içe aktarılamadı: Geçersiz kontrol paneli veri yapısı.", + "dashboard-import-missing-aliases-title": "İçe aktarılan kontrol paneli tarafından kullanılan aygıt kısa adlarını yapılandırın", + "create-new-widget": "Yeni gösterge oluştur", + "import-widget": "Göstergeyi içe aktar", + "widget-file": "Gösterge dosyası", + "invalid-widget-file-error": "Gösterge içe aktarılamadı: Geçersiz gösterge veri yapısı.", + "widget-import-missing-aliases-title": "İçe aktarılan gösterge tarafından kullanılan aygıt kısa adlarını yapılandırın", + "open-toolbar": "Kontrol paneli araç çubuğunu aç", + "close-toolbar": "Araç çubuğunu kapat", + "configuration-error": "Yapılandırma hatası", + "alias-resolution-error-title": "Kontro paneli kısa adları yapılandırma hatası", + "invalid-aliases-config": "Kısa ad filtresiyle eşleşen aygıt bulunamadı.
", + "select-devices": "Aygıt seçin", + "assignedToCustomer": "Kullanıcı grubuna atandı", + "assignedToCustomers": "Kullanıcılara atandı", + "public": "Açık", + "public-link": "Açık bağlantı", + "copy-public-link": "Açık bağlantıyı kopyala", + "public-link-copied-message": "Kontrol paneli açık bağlantısı panoya kopyalandı", + "manage-states": "Kontrol paneli durumlarını yönet", + "states": "Kontrol paneli durumları", + "search-states": "Kontrol paneli durumu ara", + "selected-states": "{ count, plural, 1 {1 kontrol paneli durumu} other {# kontrol paneli durumu} } seçildi", + "edit-state": "Kontrol paneli durumu düzenle", + "delete-state": "Kontrol paneli durumunu sil", + "add-state": "Kontrol paneli durumu ekle", + "state": "Kontrol paneli durumu", + "state-name": "İsim", + "state-name-required": "Kontrol paneli durumu ismi gerekli.", + "state-id": "Durum Kimliği", + "state-id-required": "Kontrol paneli durum kimliği gerekli.", + "state-id-exists": "Aynı kimlikte bir kontrol paneli durumu mevcut.", + "is-root-state": "Kök durum", + "delete-state-title": "Kontrol paneli durumunu sil", + "delete-state-text": "'{{stateName}}' isimli kontrol paneli durumunu silmek istediğinize emin misiniz?", + "show-details": "Detayları göster", + "hide-details": "Detayları gizle", + "select-state": "Hedef durumu seç", + "state-controller": "Durum denetleyicisi" + }, + "datakey": { + "settings": "Ayarlar", + "advanced": "İleri düzey", + "label": "Etiket", + "color": "Renk", + "units": "Değerin yanında göstermek için özel simge", + "decimals": "Noktadan sonraki basamak sayısı", + "data-generation-func": "Veri oluşturma fonksiyonu", + "use-data-post-processing-func": "Veri işleme sonrası fonksiyonunu kullanın", + "configuration": "Veri anahtarı yapılandırması", + "timeseries": "Zaman serisi", + "attributes": "Öznitelikler", + "alarm": "Alarm alanları", + "timeseries-required": "Zaman serisi öğesi gerekli.", + "timeseries-or-attributes-required": "Zaman serisi/öznitelikler öğesi gerekli.", + "maximum-timeseries-or-attributes": "Maksimum { count, plural, 1 {1 zamanserisi/öznitelik kabul edilir.} other {# zamanserisi/öznitelik kabul edilir} }", + "alarm-fields-required": "Alarm alanları gerekli.", + "function-types": "Fonksiyon türleri", + "function-types-required": "Fonksiyon türleri gerekli.", + "maximum-function-types": "Maksimum { count, plural, 1 {1 fonksiyon türü kabul edilir.} other {# fonksiyon türü kabul edilir} }" + }, + "datasource": { + "type": "Veri kaynağı türü", + "name": "İsim", + "add-datasource-prompt": "Lütfen veri kaynağı ekleyin" + }, + "details": { + "edit-mode": "Düzenleme modu", + "toggle-edit-mode": "Düzenleme modunu aç/kapat" + }, + "device": { + "device": "Aygıt", + "device-required": "Aygıt gerekli.", + "devices": "Aygıtlar", + "management": "Aygıt Yönetimi", + "view-devices": "Aygıtları görüntüle", + "device-alias": "Aygıt kısa adı", + "aliases": "Aygıt kısa adları", + "no-alias-matching": "'{{alias}}' bulunamadı.", + "no-aliases-found": "Hiçbir kısa ad bulunamadı.", + "no-key-matching": "'{{key}}' bulunamadı.", + "no-keys-found": "Hiçbir anahtar bulunamadı.", + "create-new-alias": "Yeni bir tane oluştur!", + "create-new-key": "Yeni bir tane oluştur!", + "duplicate-alias-error": "'{{alias}}' daha önce kaydedilmiş.
Aygıt kısa adları kontrol paneli özelinde emsalsiz olmalıdır.", + "configure-alias": "'{{alias}}' kısa adını yapılandırın", + "no-devices-matching": "'{{entity}}' ile eşleşen aygıt bulunamadı.", + "alias": "Kısa ad", + "alias-required": "Aygıt kısa adı gerekli.", + "remove-alias": "Aygıt kısa adını kaldır", + "add-alias": "Aygıt kısa adı ekle", + "name-starts-with": "... ile başlayan aygıt adı", + "device-list": "Aygıt listesi", + "use-device-name-filter": "Filtre kullan", + "device-list-empty": "Hiçbir aygıt seçilmedi.", + "device-name-filter-required": "Aygıt adı filtresi gerekli.", + "device-name-filter-no-device-matched": "'{{device}}' ile başlayan herhangi bir aygıt bulunamadı.", + "add": "Aygıt ekle", + "assign-to-customer": "Kullanıcı grubuna ata", + "assign-device-to-customer": "Aygıt(lar)ı Kullanıcı Grubuna Ata", + "assign-device-to-customer-text": "Lütfen kullanıcı grubuna atanacak aygıtları seçin", + "make-public": "Aygıtı açık hale getir", + "make-private": "Aygıtı gizli hale getir", + "no-devices-text": "Hiçbir aygıt bulunamadı", + "assign-to-customer-text": "Lütfen aygıt(lar)ı atayacak kullanıcı grubu seçin", + "device-details": "Aygıt detayları", + "add-device-text": "Yeni aygıt ekle", + "credentials": "Kimlik bilgileri", + "manage-credentials": "Kimlik bilgilerini yönet", + "delete": "Aygıt sil", + "assign-devices": "Aygıt ata", + "assign-devices-text": "{ count, plural, 1 {1 aygıtı} other {# aygıtı} } kullanıcı grubuna ata", + "delete-devices": "Aygıtları sil", + "unassign-from-customer": "Kullanıcı Grubundan atamayı kaldır", + "unassign-devices": "Aygıtlardan atamayı kaldır", + "unassign-devices-action-title": "{ count, plural, 1 {1 aygıtın} other {# aygıtın} } atamasını kullanıcı grubundan kaldır", + "assign-new-device": "Yeni aygıt ata", + "make-public-device-title": "'{{deviceName}}' isimli aygıtı açık hale getirmek istediğinizden emin misiniz?", + "make-public-device-text": "Onaylandıktan sonra aygıt ve verileri açık hale getirilecek ve diğerleri tarafından erişilebilir olacak.", + "make-private-device-title": "'{{deviceName}}' isimli aygıtı gizli hale getirmek istediğinizden emin misiniz?", + "make-private-device-text": "Onaylandıktan sonra aygıt ve verileri gizli hale getirilecek ve diğerleri tarafından erişilemez olacak.", + "view-credentials": "Kimlik bilgilerini görüntüle", + "delete-device-title": "'{{deviceName}}' isimli aygıtı silmek istediğinize emin misiniz?", + "delete-device-text": "UYARI: Onaylandıktan sonra aygıt ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "delete-devices-title": "{ count, plural, 1 {1 aygıtı} other {# aygıtı} } silmek istediğinize emin misiniz?", + "delete-devices-action-title": "{ count, plural, 1 {1 aygıtı} other {# aygıtı} } sil", + "delete-devices-text": "UYARI: Onaylandıktan sonra tüm seçili aygıtlar ve ilişkili verileri geri yüklenemez şekilde silinecek.", + "unassign-device-title": "'{{deviceName}}' isimli aygıtın atamasını kaldırmak istediğinize emin misiniz?", + "unassign-device-text": "Onaylandıktan sonra aygıtın ataması kaldırılacak ve kullanıcı grubu tarafından erişilemez olacak.", + "unassign-device": "Aygıt atamasını kaldır", + "unassign-devices-title": "{ count, plural, 1 {1 aygıtın} other {# aygıtın} } atamasını kaldırmak istediğinize emin misiniz?", + "unassign-devices-text": "Onaylandıktan sonra seçili aygıtların atamaları kaldırılacak ve kullanıcı grubu tarafından erişilemez olacak.", + "device-credentials": "Aygıt Kimlik Bilgileri", + "credentials-type": "Kimlik Bilgi Türü", + "access-token": "Erişim şifresi", + "access-token-required": "Erişim şifresi gerekli.", + "access-token-invalid": "Erişim şifresi uzunluğu 1 ile 20 karakter arasında olmalıdır.", + "rsa-key": "RSA açık anahtarı", + "rsa-key-required": "RSA açık anahtarı gerekli.", + "secret": "Secret", + "secret-required": "Secret gerekli.", + "device-type": "Aygıt Türü", + "device-type-required": "Aygıt türü gereli.", + "select-device-type": "Aygıt türü seç", + "enter-device-type": "Aygıt türü gir", + "any-device": "Herhangi bir aygıt", + "no-device-types-matching": "'{{entitySubtype}}' ile eşleşen aygıt türü bulunamadı.", + "device-type-list-empty": "Hiçbir aygıt türü seçilmedi.", + "device-types": "Aygıt türleri", + "name": "İsim", + "name-required": "İsim gerekli.", + "description": "Açıklama", + "events": "Olaylar", + "details": "Detaylar", + "copyId": "Aygıt kimliğini kopyala", + "copyAccessToken": "Erişim şifresini kopyala", + "idCopiedMessage": "Aygıt kimliği panoya kopyalandı.", + "accessTokenCopiedMessage": "Aygıt erişim şifresi panoya kopyalandı", + "assignedToCustomer": "Kullanıcı Grubuna atandı", + "unable-delete-device-alias-title": "Aygıt kısa adı silinemedi", + "unable-delete-device-alias-text": "Aygıt kısa adı('{{deviceAlias}}'), şu göstergeler tarafından kullanıldığı için silinemedi:
{{widgetsList}}", + "is-gateway": "Ağ geçidi mi?", + "public": "Açık", + "device-public": "Aygıt açık", + "select-device": "Aygıt seç" + }, + "dialog": { + "close": "Kapat" + }, + "error": { + "unable-to-connect": "Sunucuya bağlanamadı! Lütfen internet bağlantınızı kontrol edin.", + "unhandled-error-code": "İşlenmeyen hata koud: {{errorCode}}", + "unknown-error": "Bilinmeyen hata" + }, + "entity": { + "entity": "Öğe", + "entities": "Öğeler", + "aliases": "Öğe kısa adları", + "entity-alias": "Öğe kısa adı", + "unable-delete-entity-alias-title": "Öğe kısa adı silinemedi", + "unable-delete-entity-alias-text": "Öğe kısa adı('{{entityAlias}}'), şu göstergeler tarafından kullanıldığı için silinemiyor:
{{widgetsList}}", + "duplicate-alias-error": "'{{alias}}' daha önce kaydedilmiş.
Öğe kısa adları kontrol paneli özelinde emsalsiz olmalı.", + "missing-entity-filter-error": "'{{alias}}' için filtre bulunmuyor.", + "configure-alias": "'{{alias}}' kısa adını yapılandır", + "alias": "Kısa ad", + "alias-required": "Öğe kısa adı gerekli.", + "remove-alias": "Öğe kısa adını kaldır", + "add-alias": "Öğe kısa adı ekle", + "entity-list": "Öğe listesi", + "entity-type": "Öğe türü", + "entity-types": "Öğe türleri", + "entity-type-list": "Öğe türü listesi", + "any-entity": "Herhangi bir öğe", + "enter-entity-type": "Öğe türü girin", + "no-entities-matching": "'{{entity}}' ile eşleşen öğe bulunamadı.", + "no-entity-types-matching": "'{{entityType}}' ile eşleşen öğe türü bulunamadı.", + "name-starts-with": "... ile başlayan isim", + "use-entity-name-filter": "Filtre kullan", + "entity-list-empty": "Hiçbir öğe seçilmedi.", + "entity-type-list-empty": "Hiçbir öğe türü seçilmedi.", + "entity-name-filter-required": "Öğe ismi filtresi gerekli.", + "entity-name-filter-no-entity-matched": "'{{entity}}' ile başlayan hiçbir öğe bulunamadı.", + "all-subtypes": "Tümü", + "select-entities": "Öğeleri seç", + "no-aliases-found": "Hiçbir kısa ad bulunamadı.", + "no-alias-matching": "'{{alias}}' bulunamadı.", + "create-new-alias": "Yeni bir tane oluştur!", + "key": "Anahtar", + "key-name": "Anahtar adı", + "no-keys-found": "Hiçbir anahtar bulunamadı.", + "no-key-matching": "'{{key}}' bulunamadı.", + "create-new-key": "Yeni bir tane oluştur!", + "type": "Tür", + "type-required": "Öğe türü gerekli.", + "type-device": "Aygıt", + "type-devices": "Aygıtlar", + "list-of-devices": "{ count, plural, 1 {Bir aygıt} other {# Aygıtın Listesi} }", + "device-name-starts-with": "İsimleri '{{prefix}}' ile başlayan aygıtlar", + "type-asset": "Varlık", + "type-assets": "Varlıklar", + "list-of-assets": "{ count, plural, 1 {Bir varlık} other {# Varlığın Listesi} }", + "asset-name-starts-with": "İsmi '{{prefix}}' ile başlayan varlıklar", + "type-entity-view": "Varlık Görünümü", + "type-entity-views": "Varlık Görünümleri", + "list-of-entity-views": "{ count, plural, 1 {Bir varlık görünümü} other {# varlık görüntüleme}} listesi", + "entity-view-name-starts-with": "Adı {{önek}} ile başlayan varlık görünümleri", + "type-rule": "Kural", + "type-rules": "Kurallar", + "list-of-rules": "{ count, plural, 1 {Bir kural} other {# Kuralın Listesi} }", + "rule-name-starts-with": "İsmi '{{prefix}}' ile başlayan kurallar", + "type-plugin": "Eklenti", + "type-plugins": "Eklentiler", + "list-of-plugins": "{ count, plural, 1 {Bir eklenti} other {# Eklentinin Listesi} }", + "plugin-name-starts-with": "İsmi '{{prefix}}' ile başlayan eklentiler", + "type-tenant": "Tenant", + "type-tenants": "Tenantlar", + "list-of-tenants": "{ count, plural, 1 {Bir tenant} other {# Tenantın Listesi} }", + "tenant-name-starts-with": "İsmi '{{prefix}}' ile başlayan tenantlar", + "type-customer": "Kullanıcı Grubu", + "type-customers": "Kullanıcı Grupları", + "list-of-customers": "{ count, plural, 1 {Bir Kullanıcı Grubu} other {# Kullanıcı Grupları} }", + "customer-name-starts-with": "İsmi '{{prefix}}' ile başlayan Kullanıcı Grupları", + "type-user": "Kullanıcı", + "type-users": "Kullanıcılar", + "list-of-users": "{ count, plural, 1 {Bir kullanıcı} other {# Kullanıcının Listesi} }", + "user-name-starts-with": "İsmi '{{prefix}}' ile başlayan kullanıcılar", + "type-dashboard": "Kontrol paneli", + "type-dashboards": "Kontrol panelleri", + "list-of-dashboards": "{ count, plural, 1 {Bir kontrol paneli} other {# Kontrol Panelinin Listesi} }", + "dashboard-name-starts-with": "İsmi '{{prefix}}' ile başlayan kontrol panelleri", + "type-alarm": "Alarm", + "type-alarms": "Alarmlar", + "list-of-alarms": "{ count, plural, 1 {Bir alarm} other {# Alarmın Listesi} }", + "alarm-name-starts-with": "İsmi '{{prefix}}' ile başlayan alarmlar", + "type-rulechain": "Kural zinciri", + "type-rulechains": "Kural zincirleri", + "list-of-rulechains": "{ count, plural, 1 {Bir kural zinciri} other {# kural zincirinin listesi}}", + "rulechain-name-starts-with": "İsimleri {{prefix}} ile başlayan kural zincirleri", + "type-rulenode": "Kural düğümü", + "type-rulenodes": "Kural düğümleri", + "list-of-rulenodes": "{ count, plural, 1 {Bir kural node} other {# kural düğümünün listesi}}", + "rulenode-name-starts-with": "İsimleri '{{prefix}} ile başlayan kural düğümleri", + "type-current-customer": "Mevcut Müşteri", + "search": "Öğeleri ara", + "selected-entities": "{ count, plural, 1 {1 öğe} other {# öğe} } seçildi", + "entity-name": "Öğe adı", + "details": "Öğe detayları", + "no-entities-prompt": "Hiçbir öğe bulunamadı", + "no-data": "Görüntülenecek veri yok" + }, + "entity-view": { + "entity-view": "Varlık Görünümü", + "entity-views": "Varlık Görünümleri", + "management": "Varlık Görünümü yönetimi", + "view-entity-views": "Varlık Görünümlerini Görüntüle", + "entity-view-alias": "Varlık Görünümü takma adı", + "aliases": "Varlık Görünümü takma adları", + "no-alias-matching": "'{{alias}} bulunamadı. ", + "no-aliases-found": "Takma ad bulunamadı", + "no-key-matching": "'{{anahtar bulunamadı.", + "no-keys-found": "Anahtar bulunamadı.", + "create-new-alias": "Yeni bir tane oluştur!", + "create-new-key": "Yeni bir tane oluştur!", + "duplicate-alias-error": "Yinelenen takma ad bulundu {{alias}} '.. Entity View diğer adlar, gösterge panosunda benzersiz olmalıdır. ", + "configure-alias": "Yapılandırma {{alias}} takma ad", + "no-entity-views-matching": "{{entity}} ile eşleşen hiçbir varlık yorumu bulunamadı. ", + "alias": "Alias", + "alias-required": "Varlık Görünümü takma adı gerekiyor.", + "remove-alias": "Varlık görünümü takma adını kaldır", + "add-alias": "Varlık görünümü takma adı ekle", + "name-starts-with": "Varlık Görünümü adı ile başlıyor", + "entity-view-list": "Varlık Görünümü listesi", + "use-entity-view-name-filter": "Filtre kullan", + "entity-view-list-empty": "Hiçbir varlık görüşü seçilmedi.", + "entity-view-name-filter-required": "Varlık görünüm adı filtresi gerekli.", + "entity-view-name-filter-no-entity-view-matched": "{{entityView}} ile başlayan hiçbir varlık sayısı bulunamadı.", + "add": "Varlık Görünümü Ekle", + "assign-to-customer": "Müşteriye atama", + "assign-entity-view-to-customer": "Varlık Görünümlerini Müşteriye Atama", + "assign-entity-view-to-customer-text": "Lütfen müşteriye atamak için varlık görünümlerini seçin", + "no-entity-views-text": "Varlık görüşü bulunamadı", + "assign-to-customer-text": "Lütfen varlık görünümlerini atamak için müşteriyi seçin", + "entity-view-details": "Varlık görünümü ayrıntıları", + "add-entity-view-text": "Yeni varlık görünümü ekle", + "delete": "Varlık görünümünü sil", + "assign-entity-views": "Varlık görünümleri atama", + "assign-entity-views-text": "Müşteriye { count, plural, 1 {1 entityView} other {# entityViews}} atayın ", + "delete-entity-views": "Varlık görünümlerini sil", + "unassign-from-customer": "Müşteriden atama", + "unassign-entity-views": "Varlık görünümlerini atama", + "unassign-entity-views-action-title": "Müşteriden atama { count, plural, 1 {1 entityView} other {# entityViews}}", + "assign-new-entity-view": "Yeni varlık görünümü atama", + "delete-entity-view-title": "Varlık görünümünü silmek istediğinizden emin misiniz?, {{entityViewName}} '? ", + "delete-entity-view-text": "Dikkatli olun, onaylandıktan sonra varlık görünümü ve ilgili tüm veriler kurtarılamayacak.", + "delete-entity-views-title": "{ count, plural, 1 {1 entityView} other {# entityViews}} varlık görünümüne sahip olmak istediğinizden emin misiniz?", + "delete-entity-views-action-title": "Sil { count, plural, 1 {1 entityView} other {# entityViews}}", + "delete-entity-views-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen görünümler kaldırılacak ve ilgili tüm veriler kurtarılamayacaktır.", + "unassign-entity-view-title": "Varlık görünümünün atamasını kaldırmak istediğinizden emin misiniz? {{entityViewName}} '? ", + "unassign-entity-view-text": "Onaydan sonra varlık görünümü atanmamış olacak ve müşteri tarafından erişilemeyecektir.", + "unassign-entity-view": "Varlık görünümünün atamasını kaldır", + "unassign-entity-views-title": "{ count, plural, 1 {1 entityView} other {# entityViews}} hesabının atamasını kaldırmak istediğinizden emin misiniz?", + "unassign-entity-views-text": "Onaylandıktan sonra, seçilen tüm öğe görünümleri atamadan kaldırılacak ve müşteri tarafından erişilemeyecektir.", + "entity-view-type": "Varlık Görünümü türü", + "entity-view-type-required": "Varlık Görünümü türü gerekli.", + "select-entity-view-type": "Varlık görüntüleme türünü seç", + "enter-entity-view-type": "Varlık görüntüleme türünü girin", + "any-entity-view": "Herhangi bir varlık görünümü", + "no-entity-view-types-matching": "{{entitySubtype}} ile eşleşen hiçbir varlık görüntüleme türü bulunamadı. ", + "entity-view-type-list-empty": "Hiçbir varlık görünümü türü seçilmemiş.", + "entity-view-types": "Varlık Görünümü türleri", + "name": "Ad", + "name-required": "İsim gerekli.", + "description": "Açıklama", + "events": "Etkinlikler", + "details": "Ayrıntılar", + "copyId": "Varlık görüntüleme kimliğini kopyala", + "assignedToCustomer": "Müşteriye atandı", + "unable-entity-view-device-alias-title": "Varlık görünümü takma adı silinemiyor", + "unable-entity-view-device-alias-text": "Cihaz takma adı {{entityViewAlias}} ', aşağıdaki widget (lar) tarafından kullanıldığı şekliyle silinemez:
{{widgetsList}} ", + "select-entity-view": "Varlık görünümünü seç", + "make-public": "Varlığı herkese görünür yap", + "start-ts": "Ts", + "end-ts": "End ts" + }, + "event": { + "event-type": "Olay türü", + "type-error": "Hata", + "type-lc-event": "Yaşam döngüsü olayı", + "type-stats": "İstatistikler", + "type-debug-rule-node": "Hata ayıklama", + "type-debug-rule-chain": "Hata ayıklama", + "no-events-prompt": "Hiçbir olay bulunamadı", + "error": "Hata", + "alarm": "Alarm", + "event-time": "Olay zamanı", + "server": "Sunucu", + "body": "İçerik //(Body)", + "method": "Yöntem", + "type": "Tür", + "entity": "Varlık", + "message-id": "Mesaj Kimliği", + "message-type": "Mesaj tipi", + "data-type": "Veri tipi", + "relation-type": "İlişki Türü", + "metadata": "Meta veri", + "data": "Veri", + "event": "Olay", + "status": "Durum", + "success": "Başarı", + "failed": "Başarısız oldu", + "messages-processed": "Mesajlar işlendi", + "errors-occurred": "Hatalar oluştu" + }, + "extension": { + "extensions": "Uzantılar", + "selected-extensions": "{ count, plural, 1 {1 uzantı} other {# extensions}} seçildi", + "type": "Tür", + "key": "Anahtar", + "value": "Değer", + "id": "İD", + "extension-id": "Uzantı kimliği", + "extension-type": "Uzatma tipi", + "transformer-json": "JSON *", + "unique-id-required": "Mevcut uzantı kimliği zaten mevcut.", + "delete": "Uzantıyı sil", + "add": "Uzantı eklemek", + "edit": "Uzantıyı düzenle", + "delete-extension-title": "{{ExtensionId}} uzantısını silmek istediğinizden emin misiniz? ", + "delete-extension-text": "Dikkatli olun, onaylamadan sonra uzantı ve ilgili tüm veriler kurtarılamaz.", + "delete-extensions-title": "{ count, plural, 1 {1 uzantı} other {# extensions}} silmek istediğinizden emin misiniz?", + "delete-extensions-text": "Dikkatli olun, onaylandıktan sonra tüm seçilen uzantılar kaldırılacak.", + "converters": "Dönüştürücü", + "converter-id": "Dönüştürücü kimliği", + "configuration": "Yapılandırma", + "converter-configurations": "Dönüştürücü yapılandırmaları", + "token": "Güvenlik belirteci", + "add-converter": "Dönüştürücü ekle", + "add-config": "Dönüştürücü yapılandırması ekle", + "device-name-expression": "Cihaz adı ifadesi", + "device-type-expression": "Cihaz tipi ifadesi", + "custom": "Özel", + "to-double": "Çifte", + "transformer": "Transformer", + "json-required": "Trafo jsonu gerekli.", + "json-parse": "Trafo json ayrıştırılamıyor.", + "attributes": "Öznitellikler", + "add-attribute": "Özellik ekle", + "add-map": "Eşleme elemanı ekle", + "timeseries": "Zaman serisi", + "add-timeseries": "Zaman çizelgeleri ekle", + "field-required": "Alan gereklidir", + "brokers": "Komisyoncular", + "add-broker": "Broker ekle", + "host": "Host", + "port": "Liman", + "port-range": "Liman 1'den 65535'e kadar olmalıdır.", + "ssl": "SSL", + "credentials": "Kimlik bilgileri", + "username": "Kullanıcı adı", + "password": "Parola", + "retry-interval": "Milisaniye cinsinden tekrar deneme aralığı", + "anonymous": "Anonim", + "basic": "Temel", + "pem": "PEM", + "ca-cert": "CA sertifika dosyası *", + "private-key": "Özel anahtar dosya *", + "cert": "Sertifika dosyası *", + "no-file": "Dosya seçilmedi.", + "drop-file": "Bir dosya bırakın veya yüklenecek bir dosya seçmek için tıklayın.", + "mapping": "Mapping", + "topic-filter": "Konu filtresi", + "converter-type": "Dönüştürücü tipi", + "converter-json": "Json", + "json-name-expression": "Cihaz adı json ifadesi", + "topic-name-expression": "Cihaz adı konu ifadesi", + "json-type-expression": "Cihaz tipi json ifadesi", + "topic-type-expression": "Cihaz tipi konu ifadesi", + "attribute-key-expression": "Öznitelik anahtar ifadesi", + "attr-json-key-expression": "Öznitelik anahtar json ifadesi", + "attr-topic-key-expression": "Öznitelik anahtar konu ifadesi", + "request-id-expression": "Kimlik ifadesi iste", + "request-id-json-expression": "Kimlik json ifadesi iste", + "request-id-topic-expression": "Kimlik konu ifadesini isteyin", + "response-topic-expression": "Yanıt konusu ifadesi", + "value-expression": "Değer ifadesi", + "topic": "Konu", + "timeout": "Zaman aşımı milisaniye cinsinden", + "converter-json-required": "Dönüştürücü json gerekli.", + "converter-json-parse": "Dönüştürücü json ayrıştırılamıyor.", + "filter-expression": "Filtre ifadesi", + "connect-requests": "İstekleri bağla", + "add-connect-request": "Bağlantı talebi ekle", + "disconnect-requests": "İstekleri kes", + "add-disconnect-request": "Bağlantıyı kes isteği ekle", + "attribute-requests": "Özellik istekleri", + "add-attribute-request": "Özellik isteği ekle", + "attribute-updates": "Öznitelik güncellemeleri", + "add-attribute-update": "Özellik güncellemesi ekle", + "server-side-rpc": "Sunucu tarafı RPC", + "add-server-side-rpc-request": "Sunucu tarafı RPC isteği ekle", + "device-name-filter": "Cihaz adı filtresi", + "attribute-filter": "Özellik filtresi", + "method-filter": "Yöntem filtresi", + "request-topic-expression": "Konu ifadesi iste", + "response-timeout": "Milisaniye cinsinden yanıt zaman aşımı", + "topic-expression": "Konu ifadesi", + "client-scope": "Müşteri kapsamı", + "add-device": "Cihaz ekle", + "opc-server": "Sunucular", + "opc-add-server": "Sunucu ekle", + "opc-add-server-prompt": "Lütfen sunucu ekle", + "opc-application-name": "Uygulama Adı", + "opc-application-uri": "Uygulama uri", + "opc-scan-period-in-seconds": "Saniyeler içinde tarama süresi", + "opc-security": "Güvenlik", + "opc-identity": "Kimlik", + "opc-keystore": "Keystore", + "opc-type": "Tür", + "opc-keystore-type": "Tür", + "opc-keystore-location": "Yer *", + "opc-keystore-password": "Parola", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Anahtar şifre", + "opc-device-node-pattern": "Cihaz düğümü modeli", + "opc-device-name-pattern": "Cihaz adı deseni", + "modbus-server": "Sunucular / köle", + "modbus-add-server": "Sunucu ekle / köle", + "modbus-add-server-prompt": "Lütfen sunucu / slave ekle", + "modbus-transport": "Taşıma", + "modbus-port-name": "Seri port adı", + "modbus-encoding": "Kodlama", + "modbus-parity": "Parite", + "modbus-baudrate": "Baud hızı", + "modbus-databits": "Veri bitleri", + "modbus-stopbits": "Bitleri durdur", + "modbus-databits-range": "Veri bitleri 7 ila 8 arasında olmalıdır", + "modbus-stopbits-range": "Durma bitleri 1'den 2'ye kadar olmalıdır.", + "modbus-unit-id": "Birim Kimliği", + "modbus-unit-id-range": "Birim numarası 1 ile 247 arasında olmalıdır.", + "modbus-device-name": "Cihaz adı", + "modbus-poll-period": "Anket dönemi (ms)", + "modbus-attributes-poll-period": "Nitelikler yoklama süresi (ms)", + "modbus-timeseries-poll-period": "Timeseries anket süresi (ms)", + "modbus-poll-period-range": "Anket dönemi pozitif değer olmalı", + "modbus-tag": "Etiket", + "modbus-function": "İşlev", + "modbus-register-address": "Kayıt adresi", + "modbus-register-address-range": "Kayıt adresi 0 ile 65535 arasında olmalıdır.", + "modbus-register-bit-index": "Bit endeksi", + "modbus-register-bit-index-range": "Bit endeksi 0 ile 15 arasında olmalıdır", + "modbus-register-count": "Kayıt sayısı", + "modbus-register-count-range": "Kayıt sayısı pozitif bir değer olmalıdır.", + "modbus-byte-order": "Bayt sırası", + "sync": { + "status": "Durum", + "sync": "Senkronizasyon", + "not-sync": "Eşitleme", + "last-sync-time": "Son senkronizasyon zamanı", + "not-available": "Müsait değil" + }, + "export-extensions-configuration": "İhracat uzantıları yapılandırması", + "import-extensions-configuration": "Uzantılarını içe aktarma yapılandırması", + "import-extensions": "Uzantıları içe aktar", + "import-extension": "Uzantı içe aktar", + "export-extension": "İhracat uzantısı", + "file": "Uzantılar dosyası", + "invalid-file-error": "Geçersiz uzantı dosyası" + }, + "fullscreen": { + "expand": "Tam ekran yap", + "exit": "Tam ekrandan çık", + "toggle": "Tam ekran modu aç/kapat", + "fullscreen": "Tam ekran" + }, + "function": { + "function": "Fonksiyon" + }, + "grid": { + "delete-item-title": "Bu öğeyi silmek istediğinizden emin misiniz?", + "delete-item-text": "UYARI: Onayladıktan sonra bu öğe ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", + "delete-items-title": "{ count, plural, 1 {1 öğeyi} other {# öğeyi} } silmek istediğinizden emin misiniz?", + "delete-items-action-title": "{ count, plural, 1 {1 öğeyi} other {# öğeyi} } sil", + "delete-items-text": "UYARI: Onayladıktan sonra tüm seçili öğeler ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", + "add-item-text": "Yeni öğe ekle", + "no-items-text": "Hiç bir öğe bulunamadı", + "item-details": "Öğe detayları", + "delete-item": "Öğeyi sil", + "delete-items": "Öğeleri sil", + "scroll-to-top": "Üste kaydır" + }, + "help": { + "goto-help-page": "Yardım sayfasına git" + }, + "home": { + "home": "Ana sayfa", + "profile": "Profil", + "logout": "Çıkış", + "menu": "Menü", + "avatar": "Avatar", + "open-user-menu": "Kullanıcı menüsünü aç" + }, + "import": { + "no-file": "Hiçbir dosya seçilmedi", + "drop-file": "Bir JSON dosyası bırakın veya yüklenecek bir dosyayı seçmek için tıklayın." + }, + "item": { + "selected": "Seçildi" + }, + "js-func": { + "no-return-error": "Fonksiyon bir değer dönmeli!", + "return-type-mismatch": "Fonksiyon '{{type}}' türünde bir değer dönmeli!", + "tidy": "Düzenli" + }, + "key-val": { + "key": "Anahtar", + "value": "Değer", + "remove-entry": "Girişi kaldır", + "add-entry": "Giriş ekle", + "no-data": "Giriş yok" + }, + "layout": { + "layout": "Arayüz Düzeni", + "manage": "Arayüz düzenini yönet", + "settings": "Arayüz düzeni ayarları", + "color": "Renk", + "main": "Ana", + "right": "Sağ", + "select": "Hedef düzen seç" + }, + "legend": { + "position": "Lejant konumu", + "show-max": "Maksimum değeri göster", + "show-min": "Minimum değeri göster", + "show-avg": "Ortalama değeri göster", + "show-total": "Toplam değeri göster", + "settings": "Lejant ayarları", + "min": "min", + "max": "maks", + "avg": "ort.", + "total": "toplam" + }, + "login": { + "login": "Oturum aç", + "request-password-reset": "Parola Sıfırlama İsteği Gönder", + "reset-password": "Parola Sıfırla", + "create-password": "Parola Oluştur", + "passwords-mismatch-error": "Girilen parolalar eşleşmeli!", + "password-again": "Parola tekrarı", + "sign-in": "Lütfen girişi yapın", + "username": "Kullanıcı adı (e-posta)", + "remember-me": "Beni hatırla", + "forgot-password": "Parolamı unuttum", + "password-reset": "Parola sıfırla", + "new-password": "Yeni parola", + "new-password-again": "Yeni parola tekrarı", + "password-link-sent-message": "Parola sıfırlama e-postası başarıyla gönderildi!", + "email": "E-posta" + }, + "position": { + "top": "Üst", + "bottom": "Alt", + "left": "Sol", + "right": "Sağ" + }, + "profile": { + "profile": "Profil", + "change-password": "Şifre değiştir", + "current-password": "Şimdiki şifre" + }, + "relation": { + "relations": "İlişkiler", + "direction": "Yönelim", + "search-direction": { + "FROM": "KAYNAK", + "TO": "HEDEF" + }, + "direction-type": { + "FROM": "kaynak", + "TO": "hedef" + }, + "from-relations": "Giden ilişkiler", + "to-relations": "Gelen ilişkiler", + "selected-relations": "{ count, plural, 1 {1 ilişki} other {# ilişki} } seçildi", + "type": "Tür", + "to-entity-type": "Hedef Öğe Türü", + "to-entity-name": "Hedef Öğe Adı", + "from-entity-type": "Kaynak Öğe Türü", + "from-entity-name": "Kaynak Öğe Adı", + "to-entity": "Hedef Öğe", + "from-entity": "Kaynak Öğe", + "delete": "İlişkiyi sil", + "relation-type": "İlişki türü", + "relation-type-required": "İlişki türü gerekli.", + "any-relation-type": "Her hangi bir tür", + "add": "İlişki ekle", + "edit": "İlişki düzenle", + "delete-to-relation-title": "'{{entityName}}' öğesine olan ilişkiyi silmek istediğinize emin misiniz?", + "delete-to-relation-text": "UYARI: Onaylandıktan sonra '{{entityName}}' öğesinin şimdiki öğeyle olan ilişkisi sona erecektir.", + "delete-to-relations-title": "{ count, plural, 1 {1 ilişkiyi} other {# ilişkiyi} } silmek istediğinize emin misiniz?", + "delete-to-relations-text": "UYARI: Onaylandıktan sonra tüm seçili ilişkiler kaldırılacaktır ve ilgili öğelerin şimdiki öğeyle ilişkisi sona erecektir.", + "delete-from-relation-title": "'{{entityName}}' öğesinden ilişkiyi silmek istediğinize emin misiniz?", + "delete-from-relation-text": "UYARI: Onaylandıktan sonra şimdiki öğenin '{{entityName}}' öğesiyle ilişkisi sonlandırılacaktır.", + "delete-from-relations-title": "{ count, plural, 1 {1 ilişkiyi} other {# ilişkiyi} } silmek istediğinize emin misiniz?", + "delete-from-relations-text": "UYARI: Onaylandıktan sonra tüm seçili ilişkiler kaldırılacak ve şimdiki öğenin ilgili tüm öğelerle ilişkisi sona erecektir.", + "remove-relation-filter": "İlişki filtresini kaldır", + "add-relation-filter": "İlişkisi ekle", + "any-relation": "Herhangi bir ilişki", + "relation-filters": "İlişki filtreleri", + "additional-info": "Ek bilgi (JSON)", + "invalid-additional-info": "Ek bilgi JSON'ı parse edilip işlenemedi." + }, + "rulechain": { + "rulechain": "Kural", + "rulechains": "Kurallar", + "root": "Kök", + "delete": "Kuralı sil", + "name": "İsim", + "name-required": "İsim gerekli.", + "description": "Açıklama", + "add": "Kural Ekle", + "set-root": "Kural zincirinin kökü yap", + "set-root-rulechain-title": "Kural zincirini {{ruleChainName}} root? Yapmak istediğinizden emin misiniz?", + "set-root-rulechain-text": "Onaydan sonra kural zinciri kökleşecek ve gelen tüm iletilerle ilgilenecek.", + "delete-rulechain-title": "'{{ruleName}}' isimli kuralı silmek istediğinize emin misiniz?", + "delete-rulechain-text": "UYARI: Onaylandıktan sonra kural ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "delete-rulechains-title": "{ count, plural, 1 {1 kuralı} other {# kuralı} } sikmek istediğinize emin misiniz?", + "delete-rulechains-action-title": "{ count, plural, 1 {1 kuralı} other {# kuralı} } sil", + "delete-rulechains-text": "UYARI: Onaylandıktan sonra seçili tüm kurallar ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "add-rulechain-text": "Yeni kural ekle", + "no-rulechains-text":"Hiçbir kural bulunamadı", + "rulechain-details": "Kural detayları", + "details": "Detaylar", + "events": "Olaylar", + "system": "Sistem", + "import": "Kuralı içe aktar", + "export": "Kuralı dışa aktar", + "export-failed-error": "Kural dışa aktarılamadı: {{error}}", + "create-new-rule": "Yeni kural oluştur", + "rulechain-file": "Kural dosyası", + "invalid-rulechain-file-error": "Kural içe aktarılamadı: Geçersiz kural veri yapısı.", + "copyId": "Kural kimliğini kopyala", + "idCopiedMessage": "Kural kimliği panoya kopyalandı", + "select-rulechain": "Kural seç", + "no-rulechains-matching": "'{{entity}}' ile eşleşen kural bulunamadı.", + "rulechain-required": "Kural gerekli", + "management": "Kural yönetimi", + "debug-mode": "Hata ayıklama modu" + }, + "rulenode": { + "details": "Ayrıntılar", + "events": "Etkinlikler", + "search": "Arama düğümleri", + "open-node-library": "Düğüm kütüphanesini aç", + "add": "Kural düğümü ekle", + "name": "Ad", + "name-required": "İsim gerekli.", + "type": "Tür", + "description": "Açıklama", + "delete": "Kural düğümünü sil", + "select-all-objects": "Tüm düğümleri ve bağlantıları seç", + "deselect-all-objects": "Tüm düğümlerin ve bağlantıların seçimini kaldırın", + "delete-selected-objects": "Seçilen düğümleri ve bağlantıları sil", + "delete-selected": "Silme seçildi", + "select-all": "Hepsini seç", + "copy-selected": "Seçilenleri kopyala", + "deselect-all": "Hiçbirini seçme", + "rulenode-details": "Kural düğümü ayrıntıları", + "debug-mode": "Hata ayıklama modu", + "configuration": "Yapılandırma", + "link": "Bağlantı", + "link-details": "Kural düğüm bağlantı detayları", + "add-link": "Link ekle", + "link-label": "Bağlantı etiketi", + "link-label-required": "Bağlantı etiketi gerekli.", + "custom-link-label": "Özel bağlantı etiketi", + "custom-link-label-required": "Özel bağlantı etiketi gerekli.", + "link-labels": "Link etiketleri", + "link-labels-required": "Link etiketleri gerekli.", + "no-link-labels-found": "Bağlantı etiketi bulunamadı", + "no-link-label-matching": "{{label}} bulunamadı. ", + "create-new-link-label": "Yeni bir tane oluştur!", + "type-filter": "Filtre", + "type-filter-details": "Gelen iletileri yapılandırılmış koşullara göre filtrele", + "type-enrichment": "Zenginleştirme", + "type-enrichment-details": "Mesaj Meta Verilerine ek bilgi", + "type-transformation": "Dönüşüm", + "type-transformation-details": "Mesaj yükünü ve Meta Verileri Değiştir", + "type-action": "Aksiyon", + "type-action-details": "Özel eylem gerçekleştir", + "type-external": "Dış", + "type-external-details": "Dış sistemle etkileşir", + "type-rule-chain": "Kural Zinciri", + "type-rule-chain-details": "Belirtilen Kural Zincirine gelen mesajları ilet", + "type-input": "Giriş", + "type-input-details": "Kural Zinciri'nin mantıksal girdisi, bir sonraki ilgili Kural Düğümüne gelen iletileri iletme", + "type-unknown": "Bilinmeyen", + "type-unknown-details": "Çözümlenmemiş Kural Düğümü", + "directive-is-not-loaded": "Tanımlanmış yapılandırma yönergesi {{directiveName}} 'mevcut değil. ", + "ui-resources-load-error": "Yapılandırma kullanıcı arayüzü kaynakları yüklenemedi.", + "invalid-target-rulechain": "Hedef kural zinciri çözülemiyor!", + "test-script-function": "Test komut dosyası işlevi", + "message": "Mesaj", + "message-type": "Mesaj tipi", + "select-message-type": "Mesaj tipini seç", + "message-type-required": "Mesaj türü gerekli", + "metadata": "Meta veri", + "metadata-required": "Meta veri girişleri boş bırakılamaz.", + "output": "Çıktı", + "test": "Ölçek", + "help": "Yardım et" + }, + "tenant": { + "tenant": "Tenant", + "tenants": "Tenantlar", + "management": "Tenant yönetimi", + "add": "Tenant Ekle", + "admins": "Adminler", + "manage-tenant-admins": "Tenant Adminlerini Yönet", + "delete": "Tenant sil", + "add-tenant-text": "Yeni tenant ekle", + "no-tenants-text": "Hiçbir tenant bulunamadı", + "tenant-details": "Tenant detayları", + "delete-tenant-title": "'{{tenantTitle}}' isimli tenantı silmek istediğinize emin misiniz?", + "delete-tenant-text": "UYARI: Onaylandıktan sonra tenant ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "delete-tenants-title": "{ count, plural, 1 {1 tenantı} other {# tenantı} } silmek istediğinize emin misiniz?", + "delete-tenants-action-title": "{ count, plural, 1 {1 tenantı} other {# tenantı} } sil", + "delete-tenants-text": "UYARI: Onaylandıktan sonra seçili tüm tenantlar ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir", + "title": "Başlık", + "title-required": "Başlık gerekli.", + "description": "Açıklama", + "details": "Detaylar", + "events": "Olaylar", + "copyId": "Tenant kimliğini kopyala", + "idCopiedMessage": "Tenant kimliği panoya kopyalandı", + "select-tenant": "Tenant seç", + "no-tenants-matching": "'{{entity}}' ile eşleşen tenant bulunamadı.", + "tenant-required": "Tenant gerekli" + }, + "timeinterval": { + "seconds-interval": "{ seconds, select, 1 {1 saniye} other {# saniye} }", + "minutes-interval": "{ minutes, select, 1 {1 dakika} other {# dakika} }", + "hours-interval": "{ hours, select, 1 {1 saat} other {# saat} }", + "days-interval": "{ days, select, 1 {1 gün} other {# gün} }", + "days": "Gün", + "hours": "Saat", + "minutes": "Dakika", + "seconds": "Saniye", + "advanced": "İleri düzey" + }, + "timewindow": { + "days": "{ days, select, 1 { gün } other {# gün } }", + "hours": "{ hours, select, 0 { saat } 1 {1 saat } other {# saat } }", + "minutes": "{ minutes, select, 0 { dakika } 1 {1 dakika } other {# dakika } }", + "seconds": "{ seconds, select, 0 { saniye } 1 {1 saniye } other {# saniye } }", + "realtime": "Gerçek zaman", + "history": "Tarih", + "last-prefix": "son", + "period": "{{ startTime }}'dan {{ endTime }}'a kadar", + "edit": "Zaman aralığını düzenle", + "date-range": "Tarih aralığı", + "last": "Son", + "time-period": "Zaman periyodu" + }, + "user": { + "user": "Kullanıcı", + "users": "Kullanıcılar", + "customer-users": "Kullanıcılar", + "tenant-admins": "Tenant Adminleri", + "sys-admin": "Sistem yöneticisi", + "tenant-admin": "Tenant yöneticisi", + "customer": "Kullanıcı Grubu", + "anonymous": "Anonim", + "add": "Kullanıcı ekle", + "delete": "Kullanıcı sil", + "add-user-text": "Yeni kullanıcı ekle", + "no-users-text": "Hiçbir kullanıcı bulunamadı", + "user-details": "Kullanıcı detayları", + "delete-user-title": "'{{userEmail}}' kullanıcısını silmek istediğinize emin misiniz?", + "delete-user-text": "UYARI: Onaylandıktan sonra kullanıcı ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", + "delete-users-title": "{ count, plural, 1 {1 kullanıcıyı} other {# kullanıcıyı} } sikmek istediğinize emin misiniz?", + "delete-users-action-title": "{ count, plural, 1 {1 kullancıyı} other {# kullanıcıyı} } sil", + "delete-users-text": "UYARI: Onaylandıktan sonra kullanıcı ve ilişkili tüm verileri geri yüklenemez şekilde silinecektir.", + "activation-email-sent-message": "Etkinleştirme e-postası başarılı bir şekilde gönderildi!", + "resend-activation": "Etkinleştirme e-postasını yeniden gönder", + "email": "E-posta", + "email-required": "E-posta gerekli.", + "invalid-email-format": "Geçersiz e-posta formatı.", + "first-name": "Ad", + "last-name": "Soyad", + "description": "Açıklama", + "default-dashboard": "Varsayılan kontrol paneli", + "always-fullscreen": "Her zaman tam ekran", + "select-user": "Kullanıcı se.", + "no-users-matching": "'{{entity}}' ile eşleşen kullanıcı bulunamadı.", + "user-required": "Kullanıcı gerekli", + "activation-method": "Etkinleştirme yöntemi", + "display-activation-link": "Etkinleştirme bağlantısını görüntüle", + "send-activation-mail": "Etkinleştirme e-postası gönder", + "activation-link": "Kullanıcı hesabını etkinleştirme bağlantısı", + "activation-link-text": "Kullanıcı hesabını etkinleştirmek için bağlantıyı kullanın:", + "copy-activation-link": "Etkinleştirme bağlantısını kopyala", + "activation-link-copied-message": "Kullanıcı hesabı etkinleştirme bağlantısı panoya kopyalandı", + "details": "Ayrıntılar", + "login-as-tenant-admin": "Tenant Yönetici Girişi", + "login-as-customer-user": "Kullanıcı olarak giriş yap" + }, + "value": { + "type": "Değer tğrğ", + "string": "String", + "string-value": "String değeri", + "integer": "Integer", + "integer-value": "Integer değeri", + "invalid-integer-value": "Geçersiz integer değeri", + "double": "Double", + "double-value": "Double değeri", + "boolean": "Boolean", + "boolean-value": "Boolean değeri", + "false": "Yanlış", + "true": "Doğru", + "long": "Uzun" + }, + "widget": { + "widget-library": "Gösterge Kütüphanesi", + "widget-bundle": "Gösterge Demeti", + "select-widgets-bundle": "Gösterge demeti seç", + "management": "Gösterge yönetimi", + "editor": "Gösterge düzenleyici", + "widget-type-not-found": "Gösterge yapılandırması yüklenemedi.
Muhtemelen ilgili\n gösterge türü kaldırılmış.", + "widget-type-load-error": "Gösterge şu sebeplerden dolayı yüklenemedi:", + "remove": "Göstergeyi kaldır", + "edit": "Göstergeyi düzenle", + "remove-widget-title": "'{{widgetTitle}}' isimli göstermeyi kaldırmak istediğinizden emin misiniz?", + "remove-widget-text": "UYARI: Onaylandıktan sonra gösterge ve tüm ilişkili verileri geri yüklenemez şekilde silinecek.", + "timeseries": "Zaman serisi", + "search-data": "Arama verileri", + "no-data-found": "Veri bulunamadı", + "latest-values": "Son değerler", + "rpc": "Kontrol göstergesi", + "alarm": "Alarm göstergesi", + "static": "Statik gösterge", + "select-widget-type": "Gösterge türü seç", + "missing-widget-title-error": "Gösterge başlığı belirtilmelidir!", + "widget-saved": "Gösterge kaydedildi", + "unable-to-save-widget-error": "Gösterge kaydedilemedi! Göstergede hatalar mevcut!", + "save": "Göstergeyi kaydet", + "saveAs": "Göstergeyi farklı kaydet", + "save-widget-type-as": "Gösterge türünü farklı kaydet", + "save-widget-type-as-text": "Lütfen gösterge başlığı girin veya hedef gösterge demeti seçin", + "toggle-fullscreen": "Tam ekran aç/kapat", + "run": "Göstergeyi çalıştır", + "title": "Gösterge başlığı", + "title-required": "Gösterge başlığı gerekli.", + "type": "Gösterge türü", + "resources": "Kaynaklar", + "resource-url": "JavaScript / CSS URL", + "remove-resource": "Kaynağı kaldır", + "add-resource": "Kaynak ekle", + "html": "HTML", + "tidy": "Tertiple", + "css": "CSS", + "settings-schema": "Ayarlar şeması", + "datakey-settings-schema": "Veri anahtarı ayarları şeması", + "javascript": "Javascript", + "remove-widget-type-title": "'{{widgetName}}' isimli gösterge türünü kaldırmak istediğinizden emin misiniz?", + "remove-widget-type-text": "UYARI: Onaylandıktan sonra, gösterge türü ve ilgili tüm veriler geri yüklenemez şekilde silinecektir.", + "remove-widget-type": "Gösterge türünü kaldır", + "add-widget-type": "Yeni gösterge türü ekle", + "widget-type-load-failed-error": "Gösterge türü yüklenemedi!", + "widget-template-load-failed-error": "Gösterge şablonu yüklenemedi!", + "add": "Gösterge ekle", + "undo": "Gösterge değişikliklerini geri al", + "export": "Göstergeyi dışa aktar" + }, + "widget-action": { + "header-button": "Gösterge başlık butonu", + "open-dashboard-state": "Yeni kontrol paneli durumunua git", + "update-dashboard-state": "Kontrol paneli durumunu güncelle", + "open-dashboard": "Diğer kontrol paneline git", + "custom": "Özel eylem", + "target-dashboard-state": "Hedef kontrol paneli durumu", + "target-dashboard-state-required": "Hedef kontrol paneli durumu gerekli", + "set-entity-from-widget": "Göstergeden öğe belirle", + "target-dashboard": "Hedef kontrol paneli", + "open-right-layout": "Sağdaki kontrol paneli arayüz düzenini aç(mobil görünüm)" + }, + "widgets-bundle": { + "current": "Şimdiki demet", + "widgets-bundles": "Gösterge Demetleri", + "add": "Gösterge Demeti Ekle", + "delete": "Gösterge demeti sil", + "title": "Başlık", + "title-required": "Başlık gerekli.", + "add-widgets-bundle-text": "Yeni gösterge demeti ekle", + "no-widgets-bundles-text": "Hiçbir gösterge demeti bulunamadı", + "empty": "Gösterge demeti boş", + "details": "Detaylar", + "widgets-bundle-details": "Gösterge demeti detayları", + "delete-widgets-bundle-title": "'{{widgetsBundleTitle}}' isimli gösterge demetini silmek istediğinize emin misiniz?", + "delete-widgets-bundle-text": "UYARI: Onaylandıktan sonra gösterge demeti ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "delete-widgets-bundles-title": "{ count, plural, 1 {1 gösterge demetini} other {# gösterge demetini} } silmek istediğinize emin misiniz?", + "delete-widgets-bundles-action-title": "{ count, plural, 1 {1 gösterge demetini} other {# gösterge demetini} } sil", + "delete-widgets-bundles-text": "UYARI: Onaylandıktan sonra seçili tüm gösterge demetleri ve ilişkili tüm veriler geri yüklenemez şekilde silinecektir.", + "no-widgets-bundles-matching": "'{{widgetsBundle}}' ile eşleşen gösterge demeti bulunamadı.", + "widgets-bundle-required": "Gösterge demeti gerekli.", + "system": "Sistem", + "import": "Gösterge demetini içe aktar", + "export": "Gösterge demetini dışa aktar", + "export-failed-error": "Gösterge demetini dışa aktaramadı: {{error}}", + "create-new-widgets-bundle": "Yeni gösterge demeti oluştur", + "widgets-bundle-file": "Gösterge demeti dosyası", + "invalid-widgets-bundle-file-error": "Gösterge demeti içe aktarılamadı: Geçersiz gösterge demeti veri yapısı." + }, + "widget-config": { + "data": "Veri", + "settings": "Ayarlar", + "advanced": "İleri düzey", + "title": "Başlık", + "general-settings": "Genel ayarlar", + "display-title": "Başlığı göster", + "drop-shadow": "Gölge", + "enable-fullscreen": "Tam ekranı etkinleştir", + "background-color": "Arka plan rengi", + "text-color": "Yazı rengi", + "padding": "İç aralık (Padding)", + "margin": "Dış aralık (Margin)", + "widget-style": "Gösterge stili", + "title-style": "Başlık stili", + "mobile-mode-settings": "Mobil mod ayarları", + "order": "Sıra", + "height": "Yükseklik", + "units": "Değerin yanında göstermek için özel simge", + "decimals": "Noktadan sonraki basamak sayısı", + "timewindow": "Zaman aralığı", + "use-dashboard-timewindow": "Kontrol paneli zaman aralığı kullan", + "display-legend": "Lejant göster", + "datasources": "Veri kaynakları", + "maximum-datasources": "En fazla { count, plural, 1 {1 veri kaynağı kullanılabilir.} other {# veri kaynağı kullanılabilir} }", + "datasource-type": "Tür", + "datasource-parameters": "Parametreler", + "remove-datasource": "Veri kaynağını kaldır", + "add-datasource": "Veri kaynağı ekle", + "target-device": "Hedef aygıt", + "alarm-source": "Alarm kaynağı", + "actions": "Eylemler", + "action": "Eylem", + "add-action": "Eylem ekle", + "search-actions": "Eylem ara", + "action-source": "Eylem kaynağı", + "action-source-required": "Eylem kaynağı gerekli.", + "action-name": "İsim", + "action-name-required": "Eylem ismi gerekli.", + "action-name-not-unique": "Aynı ada sahip başka bir işlem zaten var.
Eylem adı, aynı eylem kaynağı içinde emsalsiz olmalıdır.", + "action-icon": "İkon", + "action-type": "Tür", + "action-type-required": "Eylem türü gerekli.", + "edit-action": "Eylemi düzenle", + "delete-action": "Eylemi sil", + "delete-action-title": "Gösterge eylemini sil", + "delete-action-text": "'{{actionName}}' isimli gösterge eylemini silmek istediğinizden emin misiniz?" + }, + "widget-type": { + "import": "Gösterge türünü içer aktar", + "export": "Gösterge türünü dışa aktar", + "export-failed-error": "Gösterge türü dışa aktarılamadı: {{error}}", + "create-new-widget-type": "Yeni gösterge türü oluştur", + "widget-type-file": "Gösterge türü dosyası", + "invalid-widget-type-file-error": "Gösterge türü içe aktarılamadı: Geçersiz gösterge türü veri yapısı." + }, + "icon": { + "icon": "İkon", + "select-icon": "İkon seç", + "material-icons": "Material konları", + "show-all": "Tüm ikonları göster" + }, + "custom": { + "widget-action": { + "action-cell-button": "Eylem hücre butonu", + "row-click": "Satır tıklama eylemi", + "marker-click": "İşaretçi tıklama eylemi", + "tooltip-tag-action": "İpucu etiket eylemi" + } + }, + "language": { + "language": "Dil", + "locales": { + "fr_FR": "Fransızca", + "zh_CN": "Çince", + "en_US": "İngilizce", + "it_IT": "İtalyan", + "ko_KR": "Koreli", + "ru_RU": "Rusça", + "es_ES": "İspanyol", + "ja_JA": "Japonca", + "tr_TR": "Türkçe" + } + } +} \ No newline at end of file diff --git a/ui/src/app/locale/locale.constant-zh_CN.json b/ui/src/app/locale/locale.constant-zh_CN.json index 042d230223..bac1706c67 100644 --- a/ui/src/app/locale/locale.constant-zh_CN.json +++ b/ui/src/app/locale/locale.constant-zh_CN.json @@ -1444,7 +1444,8 @@ "ru_RU": "俄语", "es_ES": "西班牙语", "it_IT": "意大利", - "ja_JA": "日本" + "ja_JA": "日本", + "tr_TR": "土耳其" } } } diff --git a/ui/src/app/rulechain/rulechain.scss b/ui/src/app/rulechain/rulechain.scss index 8f773005fa..d6d264d5ef 100644 --- a/ui/src/app/rulechain/rulechain.scss +++ b/ui/src/app/rulechain/rulechain.scss @@ -84,9 +84,6 @@ .tb-panel-title { min-width: 150px; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; user-select: none; } @@ -163,10 +160,6 @@ .fc-canvas { min-width: 100%; min-height: 100%; - -webkit-user-select: none; - -khtml-user-select: none; - -moz-user-select: none; - -ms-user-select: none; user-select: none; outline: none; -webkit-touch-callout: none; @@ -441,13 +434,7 @@ } .fc-noselect { - -webkit-touch-callout: none; /* iOS Safari */ - -webkit-user-select: none; /* Safari */ - -khtml-user-select: none; /* Konqueror HTML */ - -moz-user-select: none; /* Firefox */ - -ms-user-select: none; /* Internet Explorer/Edge */ - user-select: none; /* Non-prefixed version, currently - supported by Chrome and Opera */ + user-select: none; } .fc-edge-label { @@ -495,7 +482,6 @@ font-weight: 600; text-align: center; white-space: nowrap; - -webkit-transform: translate(-50%, -50%); transform: translate(-50%, -50%); span { diff --git a/ui/src/app/rulechain/script/node-script-test.scss b/ui/src/app/rulechain/script/node-script-test.scss index 1dd174dd87..e68691d062 100644 --- a/ui/src/app/rulechain/script/node-script-test.scss +++ b/ui/src/app/rulechain/script/node-script-test.scss @@ -29,7 +29,7 @@ md-dialog.tb-node-script-test-dialog { } .tb-split { - @include box-sizing(border-box); + box-sizing: border-box; overflow-x: hidden; overflow-y: auto; } diff --git a/ui/src/app/rulechain/script/node-script-test.service.js b/ui/src/app/rulechain/script/node-script-test.service.js index 81d81c1c54..d4e6df82d5 100644 --- a/ui/src/app/rulechain/script/node-script-test.service.js +++ b/ui/src/app/rulechain/script/node-script-test.service.js @@ -121,7 +121,7 @@ export default function NodeScriptTest($q, $mdDialog, $document, ruleChainServic onShowingCallback: onShowingCallback }, fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event, onComplete: () => { onShowingCallback.onShowed(); diff --git a/ui/src/app/rulechain/script/node-script-test.tpl.html b/ui/src/app/rulechain/script/node-script-test.tpl.html index e5fb9c38de..e7f50ccc06 100644 --- a/ui/src/app/rulechain/script/node-script-test.tpl.html +++ b/ui/src/app/rulechain/script/node-script-test.tpl.html @@ -98,7 +98,7 @@ validate-content="false" ng-readonly="true" fill-height="true"> - generateReport + diff --git a/ui/src/app/services/menu.service.js b/ui/src/app/services/menu.service.js index 1c33d1f18c..5a7add7881 100644 --- a/ui/src/app/services/menu.service.js +++ b/ui/src/app/services/menu.service.js @@ -167,6 +167,12 @@ function Menu(userService, $state, $rootScope) { state: 'home.devices', icon: 'devices_other' }, + { + name: 'entity-view.entity-views', + type: 'link', + state: 'home.entityViews', + icon: 'view_quilt' + }, { name: 'widget.widget-library', type: 'link', @@ -227,6 +233,16 @@ function Menu(userService, $state, $rootScope) { } ] }, + { + name: 'entity-view.management', + places: [ + { + name: 'entity-view.entity-views', + icon: 'view_quilt', + state: 'home.entityViews' + } + ] + }, { name: 'dashboard.management', places: [ @@ -273,6 +289,12 @@ function Menu(userService, $state, $rootScope) { state: 'home.devices', icon: 'devices_other' }, + { + name: 'entity-view.entity-views', + type: 'link', + state: 'home.entityViews', + icon: 'view_quilt' + }, { name: 'dashboard.dashboards', type: 'link', @@ -301,16 +323,26 @@ function Menu(userService, $state, $rootScope) { } ] }, - { - name: 'dashboard.view-dashboards', - places: [ - { - name: 'dashboard.dashboards', - icon: 'dashboard', - state: 'home.dashboards' - } - ] - }]; + { + name: 'entity-view.management', + places: [ + { + name: 'entity-view.entity-views', + icon: 'view_quilt', + state: 'home.entityViews' + } + ] + }, + { + name: 'dashboard.view-dashboards', + places: [ + { + name: 'dashboard.dashboards', + icon: 'dashboard', + state: 'home.dashboards' + } + ] + }]; } } } diff --git a/ui/src/app/user/add-user.controller.js b/ui/src/app/user/add-user.controller.js index 20ad0beaf7..c81cd67a57 100644 --- a/ui/src/app/user/add-user.controller.js +++ b/ui/src/app/user/add-user.controller.js @@ -100,7 +100,7 @@ export default function AddUserController($scope, $mdDialog, $state, $stateParam }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: $event }).then(function () { deferred.resolve(); diff --git a/ui/src/app/user/user.controller.js b/ui/src/app/user/user.controller.js index 8d7f214b9a..ef7948daf0 100644 --- a/ui/src/app/user/user.controller.js +++ b/ui/src/app/user/user.controller.js @@ -186,7 +186,7 @@ export default function UserController(userService, toast, $scope, $mdDialog, $d }, parent: angular.element($document[0].body), fullscreen: true, - skipHide: true, + multiple: true, targetEvent: event }); } diff --git a/ui/src/app/widget/lib/CanvasDigitalGauge.js b/ui/src/app/widget/lib/CanvasDigitalGauge.js index ee8b0ed559..331ce6c5aa 100644 --- a/ui/src/app/widget/lib/CanvasDigitalGauge.js +++ b/ui/src/app/widget/lib/CanvasDigitalGauge.js @@ -209,7 +209,7 @@ export default class CanvasDigitalGauge extends canvasGauges.BaseGauge { this.elementValueClone.renderedValue = this._value; } if (angular.isUndefined(this.elementValueClone.renderedValue)) { - this.elementValueClone.renderedValue = options.minValue; + this.elementValueClone.renderedValue = this.value; } let context = this.contextValueClone; // clear the cache diff --git a/ui/src/app/widget/lib/alarm-status-filter-panel.scss b/ui/src/app/widget/lib/alarm-status-filter-panel.scss new file mode 100644 index 0000000000..4bf6eeef11 --- /dev/null +++ b/ui/src/app/widget/lib/alarm-status-filter-panel.scss @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-alarm-status-filter-panel { + min-width: 300px; + overflow: hidden; + background: #fff; + border-radius: 4px; + box-shadow: + 0 7px 8px -4px rgba(0, 0, 0, .2), + 0 13px 19px 2px rgba(0, 0, 0, .14), + 0 5px 24px 4px rgba(0, 0, 0, .12); + + md-content { + overflow: hidden; + background-color: #fff; + } +} diff --git a/ui/src/app/widget/lib/alarm-status-filter-panel.tpl.html b/ui/src/app/widget/lib/alarm-status-filter-panel.tpl.html new file mode 100644 index 0000000000..45e8414ef8 --- /dev/null +++ b/ui/src/app/widget/lib/alarm-status-filter-panel.tpl.html @@ -0,0 +1,28 @@ + + + + + + + {{ ('alarm.search-status.' + searchStatus) | translate }} + + + diff --git a/ui/src/app/widget/lib/alarms-table-widget.js b/ui/src/app/widget/lib/alarms-table-widget.js index 0696a7b873..3d91e9abbc 100644 --- a/ui/src/app/widget/lib/alarms-table-widget.js +++ b/ui/src/app/widget/lib/alarms-table-widget.js @@ -14,11 +14,15 @@ * limitations under the License. */ import './alarms-table-widget.scss'; +import './display-columns-panel.scss'; +import './alarm-status-filter-panel.scss'; /* eslint-disable import/no-unresolved, import/default */ import alarmsTableWidgetTemplate from './alarms-table-widget.tpl.html'; import alarmDetailsDialogTemplate from '../../alarm/alarm-details-dialog.tpl.html'; +import displayColumnsPanelTemplate from './display-columns-panel.tpl.html'; +import alarmStatusFilterPanelTemplate from './alarm-status-filter-panel.tpl.html'; /* eslint-enable import/no-unresolved, import/default */ @@ -45,7 +49,7 @@ function AlarmsTableWidget() { } /*@ngInject*/ -function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDialog, $document, $translate, $q, $timeout, alarmService, utils, types) { +function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDialog, $mdPanel, $document, $translate, $q, $timeout, alarmService, utils, types) { var vm = this; vm.stylesInfo = {}; @@ -60,6 +64,7 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia vm.selectedAlarms = [] vm.alarmSource = null; + vm.alarmSearchStatus = null; vm.allAlarms = []; vm.currentAlarm = null; @@ -95,14 +100,20 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia vm.onPaginate = onPaginate; vm.onRowClick = onRowClick; vm.onActionButtonClick = onActionButtonClick; + vm.actionEnabled = actionEnabled; vm.isCurrent = isCurrent; vm.openAlarmDetails = openAlarmDetails; vm.ackAlarms = ackAlarms; + vm.ackAlarm = ackAlarm; vm.clearAlarms = clearAlarms; + vm.clearAlarm = clearAlarm; vm.cellStyle = cellStyle; vm.cellContent = cellContent; + vm.editAlarmStatusFilter = editAlarmStatusFilter; + vm.editColumnsToDisplay = editColumnsToDisplay; + $scope.$watch('vm.ctx', function() { if (vm.ctx) { vm.settings = vm.ctx.settings; @@ -158,7 +169,41 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia vm.ctx.widgetActions = [ vm.searchAction ]; - vm.actionCellDescriptors = vm.ctx.actionsApi.getActionDescriptors('actionCellButton'); + vm.displayDetails = angular.isDefined(vm.settings.displayDetails) ? vm.settings.displayDetails : true; + vm.allowAcknowledgment = angular.isDefined(vm.settings.allowAcknowledgment) ? vm.settings.allowAcknowledgment : true; + vm.allowClear = angular.isDefined(vm.settings.allowClear) ? vm.settings.allowClear : true; + + if (vm.displayDetails) { + vm.actionCellDescriptors.push( + { + displayName: $translate.instant('alarm.details'), + icon: 'more_horiz', + details: true + } + ); + } + + if (vm.allowAcknowledgment) { + vm.actionCellDescriptors.push( + { + displayName: $translate.instant('alarm.acknowledge'), + icon: 'done', + acknowledge: true + } + ); + } + + if (vm.allowClear) { + vm.actionCellDescriptors.push( + { + displayName: $translate.instant('alarm.clear'), + icon: 'clear', + clear: true + } + ); + } + + vm.actionCellDescriptors = vm.actionCellDescriptors.concat(vm.ctx.actionsApi.getActionDescriptors('actionCellButton')); if (vm.settings.alarmsTitle && vm.settings.alarmsTitle.length) { vm.alarmsTitle = utils.customTranslation(vm.settings.alarmsTitle, vm.settings.alarmsTitle); @@ -170,9 +215,6 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia vm.enableSelection = angular.isDefined(vm.settings.enableSelection) ? vm.settings.enableSelection : true; vm.searchAction.show = angular.isDefined(vm.settings.enableSearch) ? vm.settings.enableSearch : true; - vm.displayDetails = angular.isDefined(vm.settings.displayDetails) ? vm.settings.displayDetails : true; - vm.allowAcknowledgment = angular.isDefined(vm.settings.allowAcknowledgment) ? vm.settings.allowAcknowledgment : true; - vm.allowClear = angular.isDefined(vm.settings.allowClear) ? vm.settings.allowClear : true; if (!vm.allowAcknowledgment && !vm.allowClear) { vm.enableSelection = false; } @@ -305,16 +347,35 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia } function onActionButtonClick($event, alarm, actionDescriptor) { - if ($event) { - $event.stopPropagation(); + if (actionDescriptor.details) { + vm.openAlarmDetails($event, alarm); + } else if (actionDescriptor.acknowledge) { + vm.ackAlarm($event, alarm); + } else if (actionDescriptor.clear) { + vm.clearAlarm($event, alarm); + } else { + if ($event) { + $event.stopPropagation(); + } + var entityId; + var entityName; + if (alarm && alarm.originator) { + entityId = alarm.originator; + entityName = alarm.originatorName; + } + vm.ctx.actionsApi.handleWidgetAction($event, actionDescriptor, entityId, entityName, {alarm: alarm}); } - var entityId; - var entityName; - if (alarm && alarm.originator) { - entityId = alarm.originator; - entityName = alarm.originatorName; + } + + function actionEnabled(alarm, actionDescriptor) { + if (actionDescriptor.acknowledge) { + return (alarm.status == types.alarmStatus.activeUnack || + alarm.status == types.alarmStatus.clearedUnack); + } else if (actionDescriptor.clear) { + return (alarm.status == types.alarmStatus.activeAck || + alarm.status == types.alarmStatus.activeUnack); } - vm.ctx.actionsApi.handleWidgetAction($event, actionDescriptor, entityId, entityName, { alarm: alarm }); + return true; } function isCurrent(alarm) { @@ -341,7 +402,7 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia parent: angular.element($document[0].body), targetEvent: $event, fullscreen: true, - skipHide: true, + multiple: true, onShowing: function(scope, element) { onShowingCallback.onShowing(scope, element); } @@ -387,6 +448,25 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia } } + function ackAlarm($event, alarm) { + if ($event) { + $event.stopPropagation(); + } + var confirm = $mdDialog.confirm() + .targetEvent($event) + .title($translate.instant('alarm.aknowledge-alarm-title')) + .htmlContent($translate.instant('alarm.aknowledge-alarm-text')) + .ariaLabel($translate.instant('alarm.acknowledge')) + .cancel($translate.instant('action.no')) + .ok($translate.instant('action.yes')); + $mdDialog.show(confirm).then(function () { + alarmService.ackAlarm(alarm.id.id).then(function () { + vm.selectedAlarms = []; + vm.subscription.update(); + }); + }); + } + function clearAlarms($event) { if ($event) { $event.stopPropagation(); @@ -420,6 +500,24 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia } } + function clearAlarm($event, alarm) { + if ($event) { + $event.stopPropagation(); + } + var confirm = $mdDialog.confirm() + .targetEvent($event) + .title($translate.instant('alarm.clear-alarm-title')) + .htmlContent($translate.instant('alarm.clear-alarm-text')) + .ariaLabel($translate.instant('alarm.clear')) + .cancel($translate.instant('action.no')) + .ok($translate.instant('action.yes')); + $mdDialog.show(confirm).then(function () { + alarmService.clearAlarm(alarm.id.id).then(function () { + vm.selectedAlarms = []; + vm.subscription.update(); + }); + }); + } function updateAlarms(preserveSelections) { if (!preserveSelections) { @@ -558,6 +656,54 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia } } + function editAlarmStatusFilter($event) { + var element = angular.element($event.target); + var position = $mdPanel.newPanelPosition() + .relativeTo(element) + .addPanelPosition($mdPanel.xPosition.ALIGN_END, $mdPanel.yPosition.BELOW); + var config = { + attachTo: angular.element($document[0].body), + controller: AlarmStatusFilterPanelController, + controllerAs: 'vm', + templateUrl: alarmStatusFilterPanelTemplate, + panelClass: 'tb-alarm-status-filter-panel', + position: position, + fullscreen: false, + locals: { + 'subscription': vm.subscription + }, + openFrom: $event, + clickOutsideToClose: true, + escapeToClose: true, + focusOnOpen: false + }; + $mdPanel.open(config); + } + + function editColumnsToDisplay($event) { + var element = angular.element($event.target); + var position = $mdPanel.newPanelPosition() + .relativeTo(element) + .addPanelPosition($mdPanel.xPosition.ALIGN_END, $mdPanel.yPosition.BELOW); + var config = { + attachTo: angular.element($document[0].body), + controller: DisplayColumnsPanelController, + controllerAs: 'vm', + templateUrl: displayColumnsPanelTemplate, + panelClass: 'tb-display-columns-panel', + position: position, + fullscreen: false, + locals: { + 'columns': vm.alarmSource.dataKeys + }, + openFrom: $event, + clickOutsideToClose: true, + escapeToClose: true, + focusOnOpen: false + }; + $mdPanel.open(config); + } + function updateAlarmSource() { vm.ctx.widgetTitle = utils.createLabelFromDatasource(vm.alarmSource, vm.alarmsTitle); @@ -570,6 +716,7 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia var dataKey = vm.alarmSource.dataKeys[d]; dataKey.title = utils.customTranslation(dataKey.label, dataKey.label); + dataKey.display = true; var keySettings = dataKey.settings; @@ -618,4 +765,19 @@ function AlarmsTableWidgetController($element, $scope, $filter, $mdMedia, $mdDia } } -} \ No newline at end of file +} + +/*@ngInject*/ +function DisplayColumnsPanelController(columns) { //eslint-disable-line + + var vm = this; + vm.columns = columns; +} + +/*@ngInject*/ +function AlarmStatusFilterPanelController(subscription, types) { //eslint-disable-line + + var vm = this; + vm.types = types; + vm.subscription = subscription; +} diff --git a/ui/src/app/widget/lib/alarms-table-widget.scss b/ui/src/app/widget/lib/alarms-table-widget.scss index 2922996700..a03152360f 100644 --- a/ui/src/app/widget/lib/alarms-table-widget.scss +++ b/ui/src/app/widget/lib/alarms-table-widget.scss @@ -44,6 +44,27 @@ &.tb-data-table { table.md-table, table.md-table.md-row-select { + th.md-column { + &.tb-action-cell { + .md-button { + /* stylelint-disable-next-line selector-max-class */ + &.md-icon-button { + width: 36px; + height: 36px; + padding: 6px; + margin: 0; + /* stylelint-disable-next-line selector-max-class */ + md-icon { + width: 24px; + height: 24px; + font-size: 24px !important; + line-height: 24px !important; + } + } + } + } + } + tbody { tr { td { @@ -51,6 +72,15 @@ width: 36px; min-width: 36px; max-width: 36px; + + .md-button[disabled] { + &.md-icon-button { + /* stylelint-disable-next-line selector-max-class */ + md-icon { + color: rgba(0, 0, 0, .38); + } + } + } } } } diff --git a/ui/src/app/widget/lib/alarms-table-widget.tpl.html b/ui/src/app/widget/lib/alarms-table-widget.tpl.html index 8480058bc2..39843e19ea 100644 --- a/ui/src/app/widget/lib/alarms-table-widget.tpl.html +++ b/ui/src/app/widget/lib/alarms-table-widget.tpl.html @@ -62,33 +62,45 @@ - - - + + - - - - - - - + + - - - -
{{ key.title }}  {{ key.title }} + + filter_list + + + {{'alarm.alarm-status-filter' | translate}} + + + + view_column + + + {{'entity.columns-to-display' | translate}} + + +
- - more_horiz - - {{ 'alarm.details' | translate }} - - - {{actionDescriptor.icon}} diff --git a/ui/src/app/widget/lib/canvas-digital-gauge.js b/ui/src/app/widget/lib/canvas-digital-gauge.js index 283a42676b..274cc97901 100644 --- a/ui/src/app/widget/lib/canvas-digital-gauge.js +++ b/ui/src/app/widget/lib/canvas-digital-gauge.js @@ -209,8 +209,8 @@ export default class TbCanvasDigitalGauge { } var value = tvPair[1]; if(value !== this.gauge.value) { - this.gauge.value = value; this.gauge._value = value; + this.gauge.value = value; } else if (this.localSettings.showTimestamp && this.gauge.timestamp != timestamp) { this.gauge.timestamp = timestamp; } diff --git a/ui/src/app/widget/lib/display-columns-panel.scss b/ui/src/app/widget/lib/display-columns-panel.scss new file mode 100644 index 0000000000..2e518cba96 --- /dev/null +++ b/ui/src/app/widget/lib/display-columns-panel.scss @@ -0,0 +1,31 @@ +/** + * Copyright © 2016-2018 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-display-columns-panel { + min-width: 300px; + overflow: hidden; + background: #fff; + border-radius: 4px; + box-shadow: + 0 7px 8px -4px rgba(0, 0, 0, .2), + 0 13px 19px 2px rgba(0, 0, 0, .14), + 0 5px 24px 4px rgba(0, 0, 0, .12); + + md-content { + overflow: hidden; + background-color: #fff; + } +} diff --git a/ui/src/app/widget/lib/display-columns-panel.tpl.html b/ui/src/app/widget/lib/display-columns-panel.tpl.html new file mode 100644 index 0000000000..42e2471603 --- /dev/null +++ b/ui/src/app/widget/lib/display-columns-panel.tpl.html @@ -0,0 +1,24 @@ + + + + + {{ column.title }} + + diff --git a/ui/src/app/widget/lib/entities-table-widget.js b/ui/src/app/widget/lib/entities-table-widget.js index d0b629d290..6cfe7a03fc 100644 --- a/ui/src/app/widget/lib/entities-table-widget.js +++ b/ui/src/app/widget/lib/entities-table-widget.js @@ -14,11 +14,13 @@ * limitations under the License. */ import './entities-table-widget.scss'; +import './display-columns-panel.scss'; /* eslint-disable import/no-unresolved, import/default */ import entitiesTableWidgetTemplate from './entities-table-widget.tpl.html'; //import entityDetailsDialogTemplate from './entitiy-details-dialog.tpl.html'; +import displayColumnsPanelTemplate from './display-columns-panel.tpl.html'; /* eslint-enable import/no-unresolved, import/default */ @@ -45,7 +47,7 @@ function EntitiesTableWidget() { } /*@ngInject*/ -function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $translate, $timeout, utils, types) { +function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $mdPanel, $document, $translate, $timeout, utils, types) { var vm = this; vm.stylesInfo = {}; @@ -98,6 +100,8 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra vm.cellStyle = cellStyle; vm.cellContent = cellContent; + vm.editColumnsToDisplay = editColumnsToDisplay; + $scope.$watch('vm.ctx', function() { if (vm.ctx && vm.ctx.defaultSubscription) { vm.settings = vm.ctx.settings; @@ -367,7 +371,9 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra content = strContent; } } else { - content = defaultContent(key, value); + var decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : vm.widgetConfig.decimals; + var units = contentInfo.units || vm.widgetConfig.units; + content = vm.ctx.utils.formatValue(value, decimals, units, true); } return content; } else { @@ -375,14 +381,6 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra } } - function defaultContent(key, value) { - if (angular.isDefined(value)) { - return value; - } else { - return ''; - } - } - function defaultStyle(/*key, value*/) { return {}; } @@ -414,12 +412,37 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra } } + function editColumnsToDisplay($event) { + var element = angular.element($event.target); + var position = $mdPanel.newPanelPosition() + .relativeTo(element) + .addPanelPosition($mdPanel.xPosition.ALIGN_END, $mdPanel.yPosition.BELOW); + var config = { + attachTo: angular.element($document[0].body), + controller: DisplayColumnsPanelController, + controllerAs: 'vm', + templateUrl: displayColumnsPanelTemplate, + panelClass: 'tb-display-columns-panel', + position: position, + fullscreen: false, + locals: { + 'columns': vm.columns + }, + openFrom: $event, + clickOutsideToClose: true, + escapeToClose: true, + focusOnOpen: false + }; + $mdPanel.open(config); + } + function updateDatasources() { vm.stylesInfo = {}; vm.contentsInfo = {}; vm.columnWidth = {}; vm.dataKeys = []; + vm.columns = []; vm.allEntities = []; var datasource; @@ -429,6 +452,42 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra vm.ctx.widgetTitle = utils.createLabelFromDatasource(datasource, vm.entitiesTitle); + if (vm.displayEntityName) { + vm.columns.push( + { + name: 'entityName', + label: 'entityName', + title: vm.entityNameColumnTitle, + display: true + } + ); + vm.contentsInfo['entityName'] = { + useCellContentFunction: false + }; + vm.stylesInfo['entityName'] = { + useCellStyleFunction: false + }; + vm.columnWidth['entityName'] = '0px'; + } + + if (vm.displayEntityType) { + vm.columns.push( + { + name: 'entityType', + label: 'entityType', + title: $translate.instant('entity.entity-type'), + display: true + } + ); + vm.contentsInfo['entityType'] = { + useCellContentFunction: false + }; + vm.stylesInfo['entityType'] = { + useCellStyleFunction: false + }; + vm.columnWidth['entityType'] = '0px'; + } + for (var d = 0; d < datasource.dataKeys.length; d++ ) { dataKey = angular.copy(datasource.dataKeys[d]); if (dataKey.type == types.dataKeyType.function) { @@ -477,11 +536,16 @@ function EntitiesTableWidgetController($element, $scope, $filter, $mdMedia, $tra vm.contentsInfo[dataKey.label] = { useCellContentFunction: useCellContentFunction, - cellContentFunction: cellContentFunction + cellContentFunction: cellContentFunction, + units: dataKey.units, + decimals: dataKey.decimals }; var columnWidth = angular.isDefined(keySettings.columnWidth) ? keySettings.columnWidth : '0px'; vm.columnWidth[dataKey.label] = columnWidth; + + dataKey.display = true; + vm.columns.push(dataKey); } for (var i=0;i
{{vm.entityNameColumnTitle}}entity.entity-type{{ key.title }} {{ column.title }} + + view_column + + + {{'entity.columns-to-display' | translate}} + + +
{{entity.entityName}}{{entity.entityType}} + diff --git a/ui/src/app/widget/lib/flot-widget.js b/ui/src/app/widget/lib/flot-widget.js index 1f363d627c..acaaf10791 100644 --- a/ui/src/app/widget/lib/flot-widget.js +++ b/ui/src/app/widget/lib/flot-widget.js @@ -333,6 +333,7 @@ export default class TbFlot { lineWidth: 0, fill: 0.9 } + ctx.defaultBarWidth = settings.defaultBarWidth || 600; } if (this.chartType === 'state') { @@ -476,7 +477,11 @@ export default class TbFlot { this.options.yaxes = angular.copy(this.yaxes); if (this.chartType === 'line' || this.chartType === 'bar' || this.chartType === 'state') { if (this.chartType === 'bar') { - this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + if (this.subscription.timeWindowConfig.aggregation && this.subscription.timeWindowConfig.aggregation.type === 'NONE') { + this.options.series.bars.barWidth = this.ctx.defaultBarWidth; + } else { + this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + } } this.options.xaxis.min = this.subscription.timeWindow.minTime; this.options.xaxis.max = this.subscription.timeWindow.maxTime; @@ -594,7 +599,11 @@ export default class TbFlot { this.options.xaxis.min = this.subscription.timeWindow.minTime; this.options.xaxis.max = this.subscription.timeWindow.maxTime; if (this.chartType === 'bar') { - this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + if (this.subscription.timeWindowConfig.aggregation && this.subscription.timeWindowConfig.aggregation.type === 'NONE') { + this.options.series.bars.barWidth = this.ctx.defaultBarWidth; + } else { + this.options.series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + } } if (axisVisibilityChanged) { @@ -603,7 +612,11 @@ export default class TbFlot { this.ctx.plot.getOptions().xaxes[0].min = this.subscription.timeWindow.minTime; this.ctx.plot.getOptions().xaxes[0].max = this.subscription.timeWindow.maxTime; if (this.chartType === 'bar') { - this.ctx.plot.getOptions().series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + if (this.subscription.timeWindowConfig.aggregation && this.subscription.timeWindowConfig.aggregation.type === 'NONE') { + this.ctx.plot.getOptions().series.bars.barWidth = this.ctx.defaultBarWidth; + } else { + this.ctx.plot.getOptions().series.bars.barWidth = this.subscription.timeWindow.interval * 0.6; + } } this.updateData(); } @@ -810,238 +823,257 @@ export default class TbFlot { } } - static get settingsSchema() { - return { + static settingsSchema(chartType) { + + var schema = { "schema": { "type": "object", "title": "Settings", "properties": { - "stack": { - "title": "Stacking", - "type": "boolean", - "default": false - }, - "smoothLines": { - "title": "Display smooth (curved) lines", - "type": "boolean", - "default": false - }, - "shadowSize": { - "title": "Shadow size", - "type": "number", - "default": 4 - }, - "fontColor": { - "title": "Font color", + } + } + }; + + var properties = schema["schema"]["properties"]; + properties["stack"] = { + "title": "Stacking", + "type": "boolean", + "default": false + }; + if (chartType === 'graph') { + properties["smoothLines"] = { + "title": "Display smooth (curved) lines", + "type": "boolean", + "default": false + }; + } + if (chartType === 'bar') { + properties["defaultBarWidth"] = { + "title": "Default bar width for non-aggregated data (milliseconds)", + "type": "number", + "default": 600 + }; + } + properties["shadowSize"] = { + "title": "Shadow size", + "type": "number", + "default": 4 + }; + properties["fontColor"] = { + "title": "Font color", + "type": "string", + "default": "#545454" + }; + properties["fontSize"] = { + "title": "Font size", + "type": "number", + "default": 10 + }; + properties["tooltipIndividual"] = { + "title": "Hover individual points", + "type": "boolean", + "default": false + }; + properties["tooltipCumulative"] = { + "title": "Show cumulative values in stacking mode", + "type": "boolean", + "default": false + }; + properties["tooltipValueFormatter"] = { + "title": "Tooltip value format function, f(value)", + "type": "string", + "default": "" + }; + + properties["grid"] = { + "title": "Grid settings", + "type": "object", + "properties": { + "color": { + "title": "Primary color", "type": "string", "default": "#545454" - }, - "fontSize": { - "title": "Font size", + }, + "backgroundColor": { + "title": "Background color", + "type": "string", + "default": null + }, + "tickColor": { + "title": "Ticks color", + "type": "string", + "default": "#DDDDDD" + }, + "outlineWidth": { + "title": "Grid outline/border width (px)", "type": "number", - "default": 10 - }, - "tooltipIndividual": { - "title": "Hover individual points", + "default": 1 + }, + "verticalLines": { + "title": "Show vertical lines", "type": "boolean", - "default": false - }, - "tooltipCumulative": { - "title": "Show cumulative values in stacking mode", + "default": true + }, + "horizontalLines": { + "title": "Show horizontal lines", "type": "boolean", - "default": false - }, - "tooltipValueFormatter": { - "title": "Tooltip value format function, f(value)", - "type": "string", - "default": "" - }, - "grid": { - "title": "Grid settings", - "type": "object", - "properties": { - "color": { - "title": "Primary color", - "type": "string", - "default": "#545454" - }, - "backgroundColor": { - "title": "Background color", - "type": "string", - "default": null - }, - "tickColor": { - "title": "Ticks color", - "type": "string", - "default": "#DDDDDD" - }, - "outlineWidth": { - "title": "Grid outline/border width (px)", - "type": "number", - "default": 1 - }, - "verticalLines": { - "title": "Show vertical lines", - "type": "boolean", - "default": true - }, - "horizontalLines": { - "title": "Show horizontal lines", - "type": "boolean", - "default": true - } - } - }, - "xaxis": { - "title": "X axis settings", - "type": "object", - "properties": { - "showLabels": { - "title": "Show labels", - "type": "boolean", - "default": true - }, - "title": { - "title": "Axis title", - "type": "string", - "default": null - }, - "titleAngle": { - "title": "Axis title's angle in degrees", - "type": "number", - "default": 0 - }, - "color": { - "title": "Ticks color", - "type": "string", - "default": null - } - } - }, - "yaxis": { - "title": "Y axis settings", - "type": "object", - "properties": { - "min": { - "title": "Minimum value on the scale", - "type": "number", - "default": null - }, - "max": { - "title": "Maximum value on the scale", - "type": "number", - "default": null - }, - "showLabels": { - "title": "Show labels", - "type": "boolean", - "default": true - }, - "title": { - "title": "Axis title", - "type": "string", - "default": null - }, - "titleAngle": { - "title": "Axis title's angle in degrees", - "type": "number", - "default": 0 - }, - "color": { - "title": "Ticks color", - "type": "string", - "default": null - }, - "ticksFormatter": { - "title": "Ticks formatter function, f(value)", - "type": "string", - "default": "" - }, - "tickDecimals": { - "title": "The number of decimals to display", - "type": "number", - "default": 0 - }, - "tickSize": { - "title": "Step size between ticks", - "type": "number", - "default": null - } - } - } + "default": true + } + } + }; + + properties["xaxis"] = { + "title": "X axis settings", + "type": "object", + "properties": { + "showLabels": { + "title": "Show labels", + "type": "boolean", + "default": true }, - "required": [] - }, - "form": [ - "stack", - "smoothLines", - "shadowSize", + "title": { + "title": "Axis title", + "type": "string", + "default": null + }, + "titleAngle": { + "title": "Axis title's angle in degrees", + "type": "number", + "default": 0 + }, + "color": { + "title": "Ticks color", + "type": "string", + "default": null + } + } + }; + + properties["yaxis"] = { + "title": "Y axis settings", + "type": "object", + "properties": { + "min": { + "title": "Minimum value on the scale", + "type": "number", + "default": null + }, + "max": { + "title": "Maximum value on the scale", + "type": "number", + "default": null + }, + "showLabels": { + "title": "Show labels", + "type": "boolean", + "default": true + }, + "title": { + "title": "Axis title", + "type": "string", + "default": null + }, + "titleAngle": { + "title": "Axis title's angle in degrees", + "type": "number", + "default": 0 + }, + "color": { + "title": "Ticks color", + "type": "string", + "default": null + }, + "ticksFormatter": { + "title": "Ticks formatter function, f(value)", + "type": "string", + "default": "" + }, + "tickDecimals": { + "title": "The number of decimals to display", + "type": "number", + "default": 0 + }, + "tickSize": { + "title": "Step size between ticks", + "type": "number", + "default": null + } + } + }; + + schema["schema"]["required"] = []; + schema["form"] = ["stack"]; + if (chartType === 'graph') { + schema["form"].push("smoothLines"); + } + if (chartType === 'bar') { + schema["form"].push("defaultBarWidth"); + } + schema["form"].push("shadowSize"); + schema["form"].push({ + "key": "fontColor", + "type": "color" + }); + schema["form"].push("fontSize"); + schema["form"].push("tooltipIndividual"); + schema["form"].push("tooltipCumulative"); + schema["form"].push({ + "key": "tooltipValueFormatter", + "type": "javascript" + }); + schema["form"].push({ + "key": "grid", + "items": [ { - "key": "fontColor", + "key": "grid.color", "type": "color" }, - "fontSize", - "tooltipIndividual", - "tooltipCumulative", { - "key": "tooltipValueFormatter", - "type": "javascript" + "key": "grid.backgroundColor", + "type": "color" }, { - "key": "grid", - "items": [ - { - "key": "grid.color", - "type": "color" - }, - { - "key": "grid.backgroundColor", - "type": "color" - }, - { - "key": "grid.tickColor", - "type": "color" - }, - "grid.outlineWidth", - "grid.verticalLines", - "grid.horizontalLines" - ] + "key": "grid.tickColor", + "type": "color" }, + "grid.outlineWidth", + "grid.verticalLines", + "grid.horizontalLines" + ] + }); + schema["form"].push({ + "key": "xaxis", + "items": [ + "xaxis.showLabels", + "xaxis.title", + "xaxis.titleAngle", { - "key": "xaxis", - "items": [ - "xaxis.showLabels", - "xaxis.title", - "xaxis.titleAngle", - { - "key": "xaxis.color", - "type": "color" - } - ] + "key": "xaxis.color", + "type": "color" + } + ] + }); + schema["form"].push({ + "key": "yaxis", + "items": [ + "yaxis.min", + "yaxis.max", + "yaxis.tickDecimals", + "yaxis.tickSize", + "yaxis.showLabels", + "yaxis.title", + "yaxis.titleAngle", + { + "key": "yaxis.color", + "type": "color" }, { - "key": "yaxis", - "items": [ - "yaxis.min", - "yaxis.max", - "yaxis.tickDecimals", - "yaxis.tickSize", - "yaxis.showLabels", - "yaxis.title", - "yaxis.titleAngle", - { - "key": "yaxis.color", - "type": "color" - }, - { - "key": "yaxis.ticksFormatter", - "type": "javascript" - } - ] + "key": "yaxis.ticksFormatter", + "type": "javascript" } - ] - } + }); + return schema; } static get pieDatakeySettingsSchema() { diff --git a/ui/src/app/widget/lib/google-map.js b/ui/src/app/widget/lib/google-map.js index 25bf510645..2fe7ce4132 100644 --- a/ui/src/app/widget/lib/google-map.js +++ b/ui/src/app/widget/lib/google-map.js @@ -44,7 +44,7 @@ export default class TbGoogleMap { function initGoogleMap() { tbMap.map = new google.maps.Map($containerElement[0], { // eslint-disable-line no-undef - scrollwheel: false, + scrollwheel: true, mapTypeId: getGoogleMapTypeId(tbMap.defaultMapType), zoom: tbMap.defaultZoomLevel || 8 }); diff --git a/ui/src/app/widget/lib/rpc/knob.scss b/ui/src/app/widget/lib/rpc/knob.scss index bf2d3e019f..722275fc00 100644 --- a/ui/src/app/widget/lib/rpc/knob.scss +++ b/ui/src/app/widget/lib/rpc/knob.scss @@ -37,8 +37,6 @@ $background-color: #e6e7e8 !default; position: relative; &[draggable] { - -moz-user-select: none; - -webkit-user-select: none; user-select: none; } diff --git a/ui/src/app/widget/lib/rpc/led-indicator.scss b/ui/src/app/widget/lib/rpc/led-indicator.scss index b1da5c6a40..3083e7274c 100644 --- a/ui/src/app/widget/lib/rpc/led-indicator.scss +++ b/ui/src/app/widget/lib/rpc/led-indicator.scss @@ -60,19 +60,11 @@ $background-color: #e6e7e8 !default; .led { position: relative; cursor: pointer; - background-image: -owg-radial-gradient(50% 50%, circle closest-corner, transparent, rgba(0, 0, 0, .25)); - background-image: -webkit-radial-gradient(50% 50%, circle closest-corner, transparent, rgba(0, 0, 0, .25)); - background-image: -moz-radial-gradient(50% 50%, circle closest-corner, transparent, rgba(0, 0, 0, .25)); - background-image: -o-radial-gradient(50% 50%, circle closest-corner, transparent, rgba(0, 0, 0, .25)); background-image: radial-gradient(50% 50%, circle closest-corner, transparent, rgba(0, 0, 0, .25)); border-radius: 50%; transition: background-color .5s, box-shadow .5s; &.disabled { - background-image: -owg-radial-gradient(50% 50%, circle closest-corner, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); - background-image: -webkit-radial-gradient(50% 50%, circle closest-corner, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); - background-image: -moz-radial-gradient(50% 50%, circle closest-corner, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); - background-image: -o-radial-gradient(50% 50%, circle closest-corner, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); background-image: radial-gradient(50% 50%, circle closest-corner, rgba(255, 255, 255, .5), rgba(0, 0, 0, .1)); } } diff --git a/ui/src/app/widget/lib/rpc/round-switch.scss b/ui/src/app/widget/lib/rpc/round-switch.scss index 2e37f632a8..c89b26bd45 100644 --- a/ui/src/app/widget/lib/rpc/round-switch.scss +++ b/ui/src/app/widget/lib/rpc/round-switch.scss @@ -59,6 +59,8 @@ $background-color: #e6e7e8 !default; .switch { position: relative; + + box-sizing: border-box; width: 260px; min-width: 260px; height: 260px; @@ -69,21 +71,14 @@ $background-color: #e6e7e8 !default; color: #424242; cursor: pointer; - -pie-background: -pie-linear-gradient(270deg, #bbb, #ddd); background: #ddd; - background: -owg-linear-gradient(270deg, #bbb, #ddd); - background: -webkit-linear-gradient(270deg, #bbb, #ddd); - background: -moz-linear-gradient(270deg, #bbb, #ddd); - background: -o-linear-gradient(270deg, #bbb, #ddd); background: linear-gradient(180deg, #bbb, #ddd); border-radius: 130px; - @include box-sizing(border-box); - - @include box-shadow( - 0 0 0 8px rgba(0,0,0,.1) - ,0 0 3px 1px rgba(0,0,0,.1) - ,inset 0 8px 3px -8px rgba(255,255,255,.4)); + box-shadow: + 0 0 0 8px rgba(0, 0, 0, .1), + 0 0 3px 1px rgba(0, 0, 0, .1), + inset 0 8px 3px -8px rgba(255, 255, 255, .4); input { display: none; @@ -95,7 +90,7 @@ $background-color: #e6e7e8 !default; width: 100%; text-align: center; - @include text-shadow(1px 1px 4px #4a4a4a); + text-shadow: 1px 1px 4px #4a4a4a; } .on { @@ -103,15 +98,15 @@ $background-color: #e6e7e8 !default; font-family: sans-serif; color: #444; - @include transition(all .1s); + transition: all .1s; } .off { bottom: 5px; - @include transition(all .1s); + transition: all .1s; - @include transform(scaleY(.85)); + transform: scaleY(.85); } .but { @@ -125,90 +120,82 @@ $background-color: #e6e7e8 !default; border-bottom-width: 0; border-radius: 400px 400px 400px 400px / 400px 400px 300px 300px; - @include box-shadow(inset 8px 6px 5px -7px #a2a2a2, - inset -8px 6px 5px -7px #a2a2a2, - inset 0 -3px 2px -2px rgba(200, 200, 200, .5), - 0 3px 3px -2px #fff, - inset 0 -230px 60px -200px rgba(255, 255, 255, .2), - inset 0 220px 40px -200px rgba(0, 0, 0, .3)); + box-shadow: + inset 8px 6px 5px -7px #a2a2a2, + inset -8px 6px 5px -7px #a2a2a2, + inset 0 -3px 2px -2px rgba(200, 200, 200, .5), + 0 3px 3px -2px #fff, + inset 0 -230px 60px -200px rgba(255, 255, 255, .2), + inset 0 220px 40px -200px rgba(0, 0, 0, .3); - @include transition(all .2s); + transition: all .2s; } .back { + + box-sizing: border-box; width: 210px; height: 210px; padding: 4px 4px; cursor: pointer; background-color: #888787; - background-image: -owg-linear-gradient(0deg, transparent 30%, transparent 70%), -owg-linear-gradient(90deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); - background-image: -webkit-linear-gradient(0deg, transparent 30%, transparent 70%), -webkit-linear-gradient(90deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); - background-image: -moz-linear-gradient(0deg, transparent 30%, transparent 70%), -moz-linear-gradient(90deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); - background-image: -o-linear-gradient(0deg, transparent 30%, transparent 70%), -o-linear-gradient(90deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); background-image: linear-gradient(-90deg, transparent 30%, transparent 70%), linear-gradient(0deg, rgba(150, 150, 150, 0) 30%, rgba(150, 150, 150, .2) 50%, rgba(150, 150, 150, 0) 70%); border-radius: 105px; - @include box-shadow(30px 30px 30px -20px rgba(58, 58, 58, .3), - -30px 30px 30px -20px rgba(58, 58, 58, .3), - 0 30px 30px 0 rgba(16, 16, 16, .3), - inset 0 -1px 0 0 #484848); - - @include box-sizing(border-box); + box-shadow: + 30px 30px 30px -20px rgba(58, 58, 58, .3), + -30px 30px 30px -20px rgba(58, 58, 58, .3), + 0 30px 30px 0 rgba(16, 16, 16, .3), + inset 0 -1px 0 0 #484848; - @include transition(all .2s); + transition: all .2s; } input:checked + .back .on, input:checked + .back .off{ - @include text-shadow(1px 1px 4px #4a4a4a); + text-shadow: 1px 1px 4px #4a4a4a; } input:checked + .back .on{ top: 10px; color: #4c4c4c; - @include transform(scaleY(.85)); + transform: scaleY(.85); } input:checked + .back .off{ bottom: 5px; color: #444; - @include transform(scaleY(1)); + transform: scaleY(1); } input:checked + .back .but{ margin-top: 20px; background: #dcdcdc; - background-image: -owg-radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); - background-image: -webkit-radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); - background-image: -moz-radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); - background-image: -o-radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); background-image: radial-gradient(50% 15%, circle closest-corner, rgba(0, 0, 0, .3), transparent); border-radius: 400px 400px 400px 400px / 300px 300px 400px 400px; - @include box-shadow(inset 8px -4px 5px -7px #a9a9a9, - inset -8px -4px 5px -7px #808080, - 0 -3px 8px -4px rgba(50, 50, 50, .4), - inset 0 3px 4px -2px #9c9c9c, - inset 0 280px 40px -200px rgba(0, 0, 0, .2), - inset 0 -200px 40px -200px rgba(180, 180, 180, .2)); + box-shadow: + inset 8px -4px 5px -7px #a9a9a9, + inset -8px -4px 5px -7px #808080, + 0 -3px 8px -4px rgba(50, 50, 50, .4), + inset 0 3px 4px -2px #9c9c9c, + inset 0 280px 40px -200px rgba(0, 0, 0, .2), + inset 0 -200px 40px -200px rgba(180, 180, 180, .2); } input:checked + .back{ padding: 2px 4px; - background-image: -owg-linear-gradient(90deg, #868686 30%, transparent 70%), -owg-linear-gradient(180deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); - background-image: -webkit-linear-gradient(90deg, #868686 30%, transparent 70%), -webkit-linear-gradient(180deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); - background-image: -moz-linear-gradient(90deg, #868686 30%, transparent 70%), -moz-linear-gradient(180deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); - background-image: -o-linear-gradient(90deg, #868686 30%, transparent 70%), -o-linear-gradient(180deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); background-image: linear-gradient(0deg, #868686 30%, transparent 70%), linear-gradient(90deg, rgba(115, 115, 115, 0) 0%, rgba(255, 255, 255, .74) 50%, rgba(105, 105, 105, 0) 100%); - @include box-shadow(30px 30px 30px -20px rgba(49, 49, 49, .1), - -30px 30px 30px -20px rgba(111, 111, 111, .1), - 0 30px 30px 0 rgba(0, 0, 0, .2), - inset 0 1px 2px 0 rgba(167, 167, 167, .6)); + box-shadow: + 30px 30px 30px -20px rgba(49, 49, 49, .1), + -30px 30px 30px -20px rgba(111, 111, 111, .1), + 0 30px 30px 0 rgba(0, 0, 0, .2), + inset 0 1px 2px 0 rgba(167, 167, 167, .6); } } } diff --git a/ui/src/app/widget/lib/timeseries-table-widget.js b/ui/src/app/widget/lib/timeseries-table-widget.js index bca79565af..d025c1e397 100644 --- a/ui/src/app/widget/lib/timeseries-table-widget.js +++ b/ui/src/app/widget/lib/timeseries-table-widget.js @@ -217,7 +217,9 @@ function TimeseriesTableWidgetController($element, $scope, $filter, $timeout) { content = strContent; } } else { - content = vm.ctx.utils.formatValue(value, contentInfo.decimals, contentInfo.units); + var decimals = (contentInfo.decimals || contentInfo.decimals === 0) ? contentInfo.decimals : vm.widgetConfig.decimals; + var units = contentInfo.units || vm.widgetConfig.units; + content = vm.ctx.utils.formatValue(value, decimals, units, true); } return content; } diff --git a/ui/src/app/widget/widget-editor.scss b/ui/src/app/widget/widget-editor.scss index cd374e37aa..0eb37afee0 100644 --- a/ui/src/app/widget/widget-editor.scss +++ b/ui/src/app/widget/widget-editor.scss @@ -19,7 +19,7 @@ $edit-toolbar-height: 40px !default; .tb-editor { .tb-split { - @include box-sizing(border-box); + box-sizing: border-box; overflow-x: hidden; overflow-y: auto; } diff --git a/ui/src/scss/animations.scss b/ui/src/scss/animations.scss index 4ecfc34743..61b9bac890 100644 --- a/ui/src/scss/animations.scss +++ b/ui/src/scss/animations.scss @@ -15,34 +15,34 @@ */ @import "~compass-sass-mixins/lib/animate"; -@include keyframes(tbMoveFromTopFade) { +@keyframes tbMoveFromTopFade { from { opacity: 0; - @include transform(translate(0, -100%)); + transform: translate(0, -100%); } } -@include keyframes(tbMoveToTopFade) { +@keyframes tbMoveToTopFade { to { opacity: 0; - @include transform(translate(0, -100%)); + transform: translate(0, -100%); } } -@include keyframes(tbMoveFromBottomFade) { +@keyframes tbMoveFromBottomFade { from { opacity: 0; - @include transform(translate(0, 100%)); + transform: translate(0, 100%); } } -@include keyframes(tbMoveToBottomFade) { +@keyframes tbMoveToBottomFade { to { opacity: 0; - @include transform(translate(0, 150%)); + transform: translate(0, 150%); } } diff --git a/ui/src/scss/main.scss b/ui/src/scss/main.scss index 27c2d3b245..c1a437a848 100644 --- a/ui/src/scss/main.scss +++ b/ui/src/scss/main.scss @@ -42,7 +42,7 @@ textarea { word-wrap: normal; white-space: nowrap; direction: ltr; - -webkit-font-feature-settings: "liga"; + -webkit-font-feature-settings: "liga"; /* stylelint-disable-line property-no-vendor-prefix */ } a { @@ -51,7 +51,7 @@ a { text-decoration: none; border-bottom: 1px solid rgba(64, 84, 178, .25); - @include transition(border-bottom .35s); + transition: border-bottom .35s; } a:hover, @@ -258,13 +258,7 @@ label { } .tb-noselect { - -webkit-touch-callout: none; /* iOS Safari */ - -webkit-user-select: none; /* Safari */ - -khtml-user-select: none; /* Konqueror HTML */ - -moz-user-select: none; /* Firefox */ - -ms-user-select: none; /* Internet Explorer/Edge */ - user-select: none; /* Non-prefixed version, currently - supported by Chrome and Opera */ + user-select: none; } .tb-readonly-label { @@ -556,7 +550,7 @@ $previewSize: 100px !default; } .tb-error-message.ng-animate { - @include transition(all .3s cubic-bezier(.55, 0, .55, .2)); + transition: all .3s cubic-bezier(.55, 0, .55, .2); } .tb-error-message.ng-enter-prepare, @@ -652,13 +646,13 @@ section.tb-top-header-buttons { } .tb-header-buttons .tb-btn-header { - @include animation(tbMoveFromTopFade .3s ease both); position: relative !important; display: inline-block !important; + animation: tbMoveFromTopFade .3s ease both; } .tb-header-buttons .tb-btn-header.ng-hide { - @include animation(tbMoveToTopFade .3s ease both); + animation: tbMoveToTopFade .3s ease both; } /*********************** @@ -669,24 +663,24 @@ section.tb-footer-buttons { position: fixed; right: 20px; bottom: 20px; - z-index: 13; + z-index: 30; pointer-events: none; } .tb-footer-buttons .tb-btn-footer { - @include animation(tbMoveFromBottomFade .3s ease both); position: relative !important; display: inline-block !important; + animation: tbMoveFromBottomFade .3s ease both; } .tb-footer-buttons .tb-btn-footer.ng-hide { - @include animation(tbMoveToBottomFade .3s ease both); + animation: tbMoveToBottomFade .3s ease both; } ._md-toast-open-bottom .tb-footer-buttons { - @include transition(all .4s cubic-bezier(.25, .8, .25, 1)); + transition: all .4s cubic-bezier(.25, .8, .25, 1); - @include transform(translate3d(0, -42px, 0)); + transform: translate3d(0, -42px, 0); } /*********************** diff --git a/ui/src/scss/mixins.scss b/ui/src/scss/mixins.scss index a7545386aa..4d2c6045d6 100644 --- a/ui/src/scss/mixins.scss +++ b/ui/src/scss/mixins.scss @@ -15,6 +15,7 @@ */ @import "~compass-sass-mixins/lib/compass"; +/* stylelint-disable selector-no-vendor-prefix */ @mixin input-placeholder { // replaces compass/css/user-interface/input-placeholder() @@ -36,6 +37,7 @@ @content; } } +/* stylelint-enable selector-no-vendor-prefix */ @mixin line-clamp($numLines: 1, $lineHeight: 1.412) { position: relative;