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 01e575ea9b..2e01cf6963 100644
--- a/application/src/main/data/json/system/widget_bundles/charts.json
+++ b/application/src/main/data/json/system/widget_bundles/charts.json
@@ -21,7 +21,7 @@
"doughnut",
"horizontal_doughnut",
"polar_area",
- "charts.radar_chart_js",
+ "radar",
"charts.basic_timeseries",
"charts.state_chart",
"charts.timeseries_bars_flot",
@@ -29,6 +29,7 @@
"charts.pie",
"charts.pie_chart_js",
"charts.doughnut_chart_js",
- "charts.polar_area_chart_js"
+ "charts.polar_area_chart_js",
+ "charts.radar_chart_js"
]
}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/alarms_table.json b/application/src/main/data/json/system/widget_types/alarms_table.json
index 5a360bcc32..2abf10e995 100644
--- a/application/src/main/data/json/system/widget_types/alarms_table.json
+++ b/application/src/main/data/json/system/widget_types/alarms_table.json
@@ -11,7 +11,7 @@
"resources": [],
"templateHtml": "\n",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.alarmsTableWidget.onDataUpdated();\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.alarmsTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.alarmsTableWidget.onEditModeChanged();\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "",
"dataKeySettingsSchema": "",
"settingsDirective": "tb-alarms-table-widget-settings",
diff --git a/application/src/main/data/json/system/widget_types/asset_admin_table.json b/application/src/main/data/json/system/widget_types/asset_admin_table.json
index 0a2eb2c37a..a74d2dd644 100644
--- a/application/src/main/data/json/system/widget_types/asset_admin_table.json
+++ b/application/src/main/data/json/system/widget_types/asset_admin_table.json
@@ -11,7 +11,7 @@
"resources": [],
"templateHtml": "\n",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesTableWidget.onEditModeChanged();\n}\n\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n },\n 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "",
"dataKeySettingsSchema": "",
"settingsDirective": "tb-entities-table-widget-settings",
diff --git a/application/src/main/data/json/system/widget_types/device_admin_table.json b/application/src/main/data/json/system/widget_types/device_admin_table.json
index af460a286c..3265554da1 100644
--- a/application/src/main/data/json/system/widget_types/device_admin_table.json
+++ b/application/src/main/data/json/system/widget_types/device_admin_table.json
@@ -11,7 +11,7 @@
"resources": [],
"templateHtml": "\n",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n },\n 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "",
"dataKeySettingsSchema": "",
"settingsDirective": "tb-entities-table-widget-settings",
diff --git a/application/src/main/data/json/system/widget_types/entities_hierarchy.json b/application/src/main/data/json/system/widget_types/entities_hierarchy.json
index 48e9c3e632..5dc2788f12 100644
--- a/application/src/main/data/json/system/widget_types/entities_hierarchy.json
+++ b/application/src/main/data/json/system/widget_types/entities_hierarchy.json
@@ -11,7 +11,7 @@
"resources": [],
"templateHtml": "\n",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesHierarchyWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'nodeSelected': {\n name: 'widget-action.node-selected',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesHierarchyWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesHierarchyWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n dataKeysOptional: true\n };\n}\n\nself.actionSources = function() {\n return {\n 'nodeSelected': {\n name: 'widget-action.node-selected',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "",
"dataKeySettingsSchema": "",
"settingsDirective": "tb-entities-hierarchy-widget-settings",
diff --git a/application/src/main/data/json/system/widget_types/entities_table.json b/application/src/main/data/json/system/widget_types/entities_table.json
index 1ebf8d4fde..ce3ec3add1 100644
--- a/application/src/main/data/json/system/widget_types/entities_table.json
+++ b/application/src/main/data/json/system/widget_types/entities_table.json
@@ -11,7 +11,7 @@
"resources": [],
"templateHtml": "\n",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'name', type: 'entityField' }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.entitiesTableWidget.onDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.entitiesTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n hasDataPageLink: true,\n warnOnPageDataOverflow: false,\n dataKeysOptional: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'name', type: 'entityField' }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n },\n 'rowDoubleClick': {\n name: 'widget-action.row-double-click',\n multiple: false\n },\n 'cellClick': {\n name: 'widget-action.cell-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "",
"dataKeySettingsSchema": "",
"settingsDirective": "tb-entities-table-widget-settings",
diff --git a/application/src/main/data/json/system/widget_types/radar.json b/application/src/main/data/json/system/widget_types/radar.json
index 18bc07ac98..06c51d6296 100644
--- a/application/src/main/data/json/system/widget_types/radar.json
+++ b/application/src/main/data/json/system/widget_types/radar.json
@@ -1,25 +1,26 @@
{
- "fqn": "charts.radar_chart_js",
+ "fqn": "radar",
"name": "Radar",
"deprecated": false,
- "image": "data:image/png;base64,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",
- "description": "Displays the latest values of the attributes or time series data for multiple entities in a radar chart. Supports numeric values only.",
+ "image": "tb-image:cmFkYXIuc3Zn:IlJhZGFyIiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgdmlld0JveD0iMCAwIDIwMCAxNjAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiByeD0iNCIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTMwLjk4NDQgMTQwLjA1MUw5OS40OTIxIDIwLjU3MzNMMTY4IDE0MC4wNTFIMzAuOTg0NFoiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjM4IiBzdHJva2Utd2lkdGg9IjAuNTcwMzEzIi8+CjxwYXRoIGQ9Ik00OC42MzU2IDEyOS42MzdMOTkuNDkyIDQwLjI2NDFMMTUwLjM0OCAxMjkuNjM3SDQ4LjYzNTZaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjA0IiBzdHJva2U9IiNFMEUwRTAiIHN0cm9rZS13aWR0aD0iMC41NzAzMTMiLz4KPHBhdGggZD0iTTk5LjQ5MiA1Ny4xODc1TDEzNS45MDIgMTIxLjE3Mkg2My4wODI1TDk5LjQ5MiA1Ny4xODc1WiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTYzLjU3MjkgMTIwLjg4N0w5OS40OTIgNTcuNzY0MUwxMzUuNDExIDEyMC44ODdINjMuNTcyOVoiIHN0cm9rZT0iYmxhY2siIHN0cm9rZS1vcGFjaXR5PSIwLjEyIiBzdHJva2Utd2lkdGg9IjAuNTcwMzEzIi8+CjxwYXRoIGQ9Ik03OS40NDM4IDExMS42MzdMOTkuNDkyMiA3Ni40MDUyTDExOS41NDEgMTExLjYzN0g3OS40NDM4WiIgZmlsbD0iYmxhY2siIGZpbGwtb3BhY2l0eT0iMC4wNCIgc3Ryb2tlPSIjRTBFMEUwIiBzdHJva2Utd2lkdGg9IjAuNTcwMzEzIi8+CjxwYXRoIGQ9Ik05OS40OTE5IDkzLjI4MTJMMTA1LjA5MyAxMDMuMTI1SDkzLjg5MDVMOTkuNDkxOSA5My4yODEyWiIgZmlsbD0id2hpdGUiLz4KPHBhdGggZD0iTTk0LjM4MDggMTAyLjg0TDk5LjQ5MTkgOTMuODU3OEwxMDQuNjAzIDEwMi44NEg5NC4zODA4WiIgc3Ryb2tlPSJibGFjayIgc3Ryb2tlLW9wYWNpdHk9IjAuMTIiIHN0cm9rZS13aWR0aD0iMC41NzAzMTMiLz4KPHBhdGggZD0iTTMxLjA2MjUgMTM5Ljc2Nkw5OS40OTQ1IDk5Ljk1ODUiIHN0cm9rZT0iIzlFOUU5RSIgc3Ryb2tlLXdpZHRoPSIwLjYxMzA5MSIvPgo8cGF0aCBkPSJNMTY3LjkyMiAxMzkuNzY2TDk5LjQ5MTkgOTkuOTU1NCIgc3Ryb2tlPSIjOUU5RTlFIiBzdHJva2Utd2lkdGg9IjAuNjEzMDkxIi8+CjxwYXRoIGQ9Ik05OS40OTIyIDIxLjE0MDFMOTkuNDkyMiA5OS44NDMzIiBzdHJva2U9IiM5RTlFOUUiIHN0cm9rZS13aWR0aD0iMC42MTMwOTEiLz4KPHBhdGggZD0iTTk5LjQyNzIgNTUuMzgyOEwxMTQuNzEgMTA4LjY5MyIgc3Ryb2tlPSIjM0Y1MkREIiBzdHJva2Utd2lkdGg9IjAuNjEzMDkxIi8+CjxwYXRoIGQ9Ik0xMTQuNzExIDEwOC42OTNMNTEuNjYyNyAxMjcuMjYxIiBzdHJva2U9IiMzRjUyREQiIHN0cm9rZS13aWR0aD0iMC42MTMwOTEiLz4KPHBhdGggZD0iTTk5LjQyNzIgNTUuMzgyOEw1MS42NjI0IDEyNy4yNjEiIHN0cm9rZT0iIzNGNTJERCIgc3Ryb2tlLXdpZHRoPSIwLjYxMzA5MSIvPgo8ZWxsaXBzZSBjeD0iNTEuNjYyOCIgY3k9IjEyNy4yNjEiIHJ4PSIyLjE3NDMyIiByeT0iMi4xODQ0NSIgZmlsbD0iIzNGNTJERCIvPgo8ZWxsaXBzZSBjeD0iOTkuNDkyMiIgY3k9IjU1LjE3NDIiIHJ4PSIyLjE3NDMyIiByeT0iMi4xODQ0NSIgZmlsbD0iIzNGNTJERCIvPgo8ZWxsaXBzZSBjeD0iMTE0LjcxMSIgY3k9IjEwOC42OTMiIHJ4PSIyLjE3NDMyIiByeT0iMi4xODQ0NSIgZmlsbD0iIzNGNTJERCIvPgo8cGF0aCBkPSJNMjguMTMyOCAxNDkuMTc2TDI2LjUxMTcgMTU0SDI1LjM3NUwyNy41IDE0OC4zMTJIMjguMjI2NkwyOC4xMzI4IDE0OS4xNzZaTTI5LjQ4ODMgMTU0TDI3Ljg1OTQgMTQ5LjE3NkwyNy43NjE3IDE0OC4zMTJIMjguNDkyMkwzMC42Mjg5IDE1NEgyOS40ODgzWk0yOS40MTQxIDE1MS44ODdWMTUyLjczNEgyNi4zNjMzVjE1MS44ODdIMjkuNDE0MVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPHBhdGggZD0iTTE3Mi4yNTQgMTUyLjEyOUgxNzMuMzI4QzE3My4yOTkgMTUyLjUwOSAxNzMuMTk0IDE1Mi44NDYgMTczLjAxMiAxNTMuMTQxQzE3Mi44MjkgMTUzLjQzMiAxNzIuNTc2IDE1My42NjEgMTcyLjI1IDE1My44MjhDMTcxLjkyNCAxNTMuOTk1IDE3MS41MyAxNTQuMDc4IDE3MS4wNjYgMTU0LjA3OEMxNzAuNzEgMTU0LjA3OCAxNzAuMzg4IDE1NC4wMTYgMTcwLjEwMiAxNTMuODkxQzE2OS44MTggMTUzLjc2MyAxNjkuNTc0IDE1My41ODIgMTY5LjM3MSAxNTMuMzQ4QzE2OS4xNzEgMTUzLjExMSAxNjkuMDE3IDE1Mi44MjcgMTY4LjkxIDE1Mi40OTZDMTY4LjgwMyAxNTIuMTYzIDE2OC43NSAxNTEuNzg5IDE2OC43NSAxNTEuMzc1VjE1MC45NDFDMTY4Ljc1IDE1MC41MjcgMTY4LjgwNSAxNTAuMTU0IDE2OC45MTQgMTQ5LjgyQzE2OS4wMjMgMTQ5LjQ4NyAxNjkuMTggMTQ5LjIwMyAxNjkuMzgzIDE0OC45NjlDMTY5LjU4OSAxNDguNzMyIDE2OS44MzUgMTQ4LjU0OSAxNzAuMTIxIDE0OC40MjJDMTcwLjQxIDE0OC4yOTQgMTcwLjczMyAxNDguMjMgMTcxLjA5IDE0OC4yM0MxNzEuNTUzIDE0OC4yMyAxNzEuOTQ1IDE0OC4zMTYgMTcyLjI2NiAxNDguNDg4QzE3Mi41ODYgMTQ4LjY1OCAxNzIuODM1IDE0OC44OTEgMTczLjAxMiAxNDkuMTg4QzE3My4xODkgMTQ5LjQ4NCAxNzMuMjk2IDE0OS44MjYgMTczLjMzMiAxNTAuMjExSDE3Mi4yNThDMTcyLjIzNyAxNDkuOTcxIDE3Mi4xODUgMTQ5Ljc2OCAxNzIuMTAyIDE0OS42MDJDMTcyLjAyMSAxNDkuNDM1IDE3MS44OTggMTQ5LjMwOSAxNzEuNzM0IDE0OS4yMjNDMTcxLjU3MyAxNDkuMTM0IDE3MS4zNTggMTQ5LjA5IDE3MS4wOSAxNDkuMDlDMTcwLjg4MiAxNDkuMDkgMTcwLjY5OCAxNDkuMTI5IDE3MC41MzkgMTQ5LjIwN0MxNzAuMzgzIDE0OS4yODUgMTcwLjI1MyAxNDkuNDAyIDE3MC4xNDggMTQ5LjU1OUMxNzAuMDQ0IDE0OS43MTIgMTY5Ljk2NiAxNDkuOTA1IDE2OS45MTQgMTUwLjEzN0MxNjkuODYyIDE1MC4zNjYgMTY5LjgzNiAxNTAuNjMyIDE2OS44MzYgMTUwLjkzNFYxNTEuMzc1QzE2OS44MzYgMTUxLjY2NCAxNjkuODU5IDE1MS45MjMgMTY5LjkwNiAxNTIuMTUyQzE2OS45NTMgMTUyLjM4MiAxNzAuMDI2IDE1Mi41NzYgMTcwLjEyNSAxNTIuNzM0QzE3MC4yMjQgMTUyLjg5MyAxNzAuMzUyIDE1My4wMTQgMTcwLjUwOCAxNTMuMDk4QzE3MC42NjQgMTUzLjE4MSAxNzAuODUgMTUzLjIyMyAxNzEuMDY2IDE1My4yMjNDMTcxLjMyOSAxNTMuMjIzIDE3MS41NDMgMTUzLjE4MSAxNzEuNzA3IDE1My4wOThDMTcxLjg3NCAxNTMuMDE0IDE3MiAxNTIuODkyIDE3Mi4wODYgMTUyLjczQzE3Mi4xNzQgMTUyLjU2OSAxNzIuMjMgMTUyLjM2OCAxNzIuMjU0IDE1Mi4xMjlaIiBmaWxsPSJibGFjayIgZmlsbC1vcGFjaXR5PSIwLjc2Ii8+CjxwYXRoIGQ9Ik0xMDAuMTg0IDguNDY0ODRIOTguNzMwNUw5OC43MjI3IDcuNzA3MDNIOTkuOTY4OEMxMDAuMTgyIDcuNzA3MDMgMTAwLjM2MSA3LjY3NzA4IDEwMC41MDQgNy42MTcxOUMxMDAuNjQ3IDcuNTU3MjkgMTAwLjc1NSA3LjQ3MDA1IDEwMC44MjggNy4zNTU0N0MxMDAuOTA0IDcuMjQwODkgMTAwLjk0MSA3LjEwMTU2IDEwMC45NDEgNi45Mzc1QzEwMC45NDEgNi43NTUyMSAxMDAuOTA2IDYuNjA2NzcgMTAwLjgzNiA2LjQ5MjE5QzEwMC43NjggNi4zNzc2IDEwMC42NjEgNi4yOTQyNyAxMDAuNTE2IDYuMjQyMTlDMTAwLjM3IDYuMTg3NSAxMDAuMTg1IDYuMTYwMTYgOTkuOTYwOSA2LjE2MDE2SDk5LjA3ODFWMTFIOTguMDAzOVY1LjMxMjVIOTkuOTYwOUMxMDAuMjg0IDUuMzEyNSAxMDAuNTcyIDUuMzQzNzUgMTAwLjgyNCA1LjQwNjI1QzEwMS4wNzkgNS40NjYxNSAxMDEuMjk2IDUuNTU5OSAxMDEuNDczIDUuNjg3NUMxMDEuNjUgNS44MTUxIDEwMS43ODQgNS45NzUyNiAxMDEuODc1IDYuMTY3OTdDMTAxLjk2OSA2LjM2MDY4IDEwMi4wMTYgNi41ODk4NCAxMDIuMDE2IDYuODU1NDdDMTAyLjAxNiA3LjA4OTg0IDEwMS45NjEgNy4zMDU5OSAxMDEuODUyIDcuNTAzOTFDMTAxLjc0NSA3LjY5OTIyIDEwMS41NzkgNy44NTgwNyAxMDEuMzU1IDcuOTgwNDdDMTAxLjEzNCA4LjEwMjg2IDEwMC44NTMgOC4xNzMxOCAxMDAuNTEyIDguMTkxNDFMMTAwLjE4NCA4LjQ2NDg0Wk0xMDAuMTM3IDExSDk4LjQxNDFMOTguODYzMyAxMC4xNTYySDEwMC4xMzdDMTAwLjM1IDEwLjE1NjIgMTAwLjUyNiAxMC4xMjExIDEwMC42NjQgMTAuMDUwOEMxMDAuODA1IDkuOTgwNDcgMTAwLjkwOSA5Ljg4NDExIDEwMC45NzcgOS43NjE3MkMxMDEuMDQ3IDkuNjM2NzIgMTAxLjA4MiA5LjQ5MzQ5IDEwMS4wODIgOS4zMzIwM0MxMDEuMDgyIDkuMTU0OTUgMTAxLjA1MSA5LjAwMTMgMTAwLjk4OCA4Ljg3MTA5QzEwMC45MjggOC43NDA4OSAxMDAuODMyIDguNjQwNjIgMTAwLjY5OSA4LjU3MDMxQzEwMC41NjkgOC41IDEwMC4zOTcgOC40NjQ4NCAxMDAuMTg0IDguNDY0ODRIOTkuMDY2NEw5OS4wNzQyIDcuNzA3MDNIMTAwLjQ5NkwxMDAuNzQyIDhDMTAxLjA3IDguMDAyNiAxMDEuMzM3IDguMDY3NzEgMTAxLjU0MyA4LjE5NTMxQzEwMS43NTEgOC4zMjI5MiAxMDEuOTA1IDguNDg2OTggMTAyLjAwNCA4LjY4NzVDMTAyLjEwMyA4Ljg4ODAyIDEwMi4xNTIgOS4xMDQxNyAxMDIuMTUyIDkuMzM1OTRDMTAyLjE1MiA5LjcwMDUyIDEwMi4wNzMgMTAuMDA2NSAxMDEuOTE0IDEwLjI1MzlDMTAxLjc1OCAxMC41MDEzIDEwMS41MjkgMTAuNjg3NSAxMDEuMjI3IDEwLjgxMjVDMTAwLjkyNyAxMC45Mzc1IDEwMC41NjQgMTEgMTAwLjEzNyAxMVoiIGZpbGw9ImJsYWNrIiBmaWxsLW9wYWNpdHk9IjAuNzYiLz4KPC9zdmc+Cg==",
+ "description": "Displays the latest values of the attributes or time-series data in a radar chart. Supports numeric values only.",
"descriptor": {
"type": "latest",
- "sizeX": 7,
- "sizeY": 5,
- "resources": [
- {
- "url": "https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.3.0/Chart.min.js"
- }
- ],
- "templateHtml": "\n",
+ "sizeX": 5,
+ "sizeY": 4,
+ "resources": [],
+ "templateHtml": "\n",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showTooltip = utils.defaultValue(settings.showTooltip, true);\n \n Chart.defaults.global.tooltips.enabled = settings.showTooltip;\n \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 var units = dataKey.units && dataKey.units.length ? dataKey.units : self.ctx.units;\n units = units ? (' (' + units + ')') : '';\n barData.labels.push(dataKey.label + units);\n dataset.data.push(0);\n }\n \n var floatingPoint;\n if (typeof self.ctx.decimals !== 'undefined' && self.ctx.decimals !== null) {\n floatingPoint = self.ctx.widget.config.decimals;\n } else {\n floatingPoint = 2;\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 scale: {\n ticks: {\n callback: function(tick) {\n \treturn tick.toFixed(floatingPoint);\n }\n }\n }\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 decimals;\n if (typeof cellData.dataKey.decimals !== 'undefined' \n && cellData.dataKey.decimals !== null ) {\n decimals = cellData.dataKey.decimals; \n } else {\n decimals = self.ctx.decimals;\n }\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = self.ctx.utils.formatValue(tvPair[1], decimals);\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\nself.onDestroy = function() {\n self.ctx.chart.destroy();\n self.ctx.chart = null;\n}\n",
+ "controllerScript": "self.onInit = function() {\n self.ctx.$scope.radarChartWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.radarChartWidget.onDataUpdated();\n};\n\nself.typeParameters = function() {\n return {\n previewWidth: '500px',\n previewHeight: '380px',\n embedTitlePanel: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'windPower', label: 'Wind', type: 'timeseries', color: '#08872B' },\n { name: 'solarPower', label: 'Solar', type: 'timeseries', color: '#FF4D5A' },\n { name: 'hydroelectricPower', label: 'Hydroelectric', type: 'timeseries', color: '#FFDE30' }];\n }\n };\n};\n\nself.onDestroy = function() {\n};\n\n",
"settingsSchema": "",
- "dataKeySettingsSchema": "{}\n",
- "settingsDirective": "tb-chart-widget-settings",
- "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\"}"
+ "dataKeySettingsSchema": "",
+ "settingsDirective": "tb-radar-chart-widget-settings",
+ "hasBasicMode": true,
+ "basicModeDirective": "tb-radar-chart-basic-config",
+ "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Wind\",\"color\":\"#08872B\",\"settings\":{},\"_hash\":0.7227918773301678,\"funcBody\":\"if (prevValue === 0) {\\n prevValue = Math.random() * 200;\\n}\\nvar value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 200) {\\n\\tvalue = 200;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Solar\",\"color\":\"#FF4D5A\",\"settings\":{},\"_hash\":0.2392660816082064,\"funcBody\":\"if (prevValue === 0) {\\n prevValue = Math.random() * 200;\\n}\\nvar value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 200) {\\n\\tvalue = 200;\\n}\\nreturn value;\",\"aggregationType\":null,\"units\":null,\"decimals\":null,\"usePostProcessing\":null,\"postFuncBody\":null},{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Hydroelectric\",\"color\":\"#FFDE30\",\"settings\":{},\"_hash\":0.7051898468567794,\"funcBody\":\"if (prevValue === 0) {\\n prevValue = Math.random() * 200;\\n}\\nvar value = prevValue + Math.random() * 20 - 10;\\nif (value < 0) {\\n\\tvalue = 0;\\n} else if (value > 200) {\\n\\tvalue = 200;\\n}\\nreturn value;\",\"decimals\":0,\"aggregationType\":null,\"usePostProcessing\":null,\"postFuncBody\":null}],\"alarmFilterConfig\":{\"statusList\":[\"ACTIVE\"]}}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"rgba(0, 0, 0, 0)\",\"color\":null,\"padding\":\"0px\",\"settings\":{},\"title\":\"Radar\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"units\":null,\"decimals\":0,\"useDashboardTimewindow\":true,\"showLegend\":false,\"widgetStyle\":{},\"actions\":{\"headerButton\":[]},\"configMode\":\"basic\",\"displayTimewindow\":true,\"margin\":\"0px\",\"borderRadius\":\"\",\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"showTitleIcon\":false,\"titleTooltip\":\"\",\"titleFont\":{\"size\":16,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"500\",\"style\":\"normal\",\"lineHeight\":\"24px\"},\"titleIcon\":\"radar\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"timewindowStyle\":{\"showIcon\":false,\"iconSize\":\"24px\",\"icon\":null,\"iconPosition\":\"left\",\"font\":{\"size\":12,\"sizeUnit\":\"px\",\"family\":\"Roboto\",\"weight\":\"400\",\"style\":\"normal\",\"lineHeight\":\"16px\"},\"color\":\"rgba(0, 0, 0, 0.38)\",\"displayTypePrefix\":true}}"
},
- "externalId": null
+ "tags": [
+ "radar",
+ "radar chart"
+ ]
}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/radar_deprecated.json b/application/src/main/data/json/system/widget_types/radar_deprecated.json
new file mode 100644
index 0000000000..0018c3d6a0
--- /dev/null
+++ b/application/src/main/data/json/system/widget_types/radar_deprecated.json
@@ -0,0 +1,25 @@
+{
+ "fqn": "charts.radar_chart_js",
+ "name": "Radar",
+ "deprecated": true,
+ "image": "tb-image:cmFkYXJfc3lzdGVtX3dpZGdldF9pbWFnZS5wbmc=:IlJhZGFyIiBzeXN0ZW0gd2lkZ2V0IGltYWdl;data:image/png;base64,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",
+ "description": "Displays the latest values of the attributes or time series data for multiple entities in a radar chart. Supports numeric values only.",
+ "descriptor": {
+ "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 $scope = self.ctx.$scope;\n utils = $scope.$injector.get(self.ctx.servicesMap.get('utils'));\n settings = utils.deepClone(self.ctx.settings) || {};\n settings.showTooltip = utils.defaultValue(settings.showTooltip, true);\n \n Chart.defaults.global.tooltips.enabled = settings.showTooltip;\n \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 var units = dataKey.units && dataKey.units.length ? dataKey.units : self.ctx.units;\n units = units ? (' (' + units + ')') : '';\n barData.labels.push(dataKey.label + units);\n dataset.data.push(0);\n }\n \n var floatingPoint;\n if (typeof self.ctx.decimals !== 'undefined' && self.ctx.decimals !== null) {\n floatingPoint = self.ctx.widget.config.decimals;\n } else {\n floatingPoint = 2;\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 scale: {\n ticks: {\n callback: function(tick) {\n \treturn tick.toFixed(floatingPoint);\n }\n }\n }\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 decimals;\n if (typeof cellData.dataKey.decimals !== 'undefined' \n && cellData.dataKey.decimals !== null ) {\n decimals = cellData.dataKey.decimals; \n } else {\n decimals = self.ctx.decimals;\n }\n var tvPair = cellData.data[cellData.data.length - 1];\n var value = self.ctx.utils.formatValue(tvPair[1], decimals);\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\nself.onDestroy = function() {\n self.ctx.chart.destroy();\n self.ctx.chart = null;\n}\n",
+ "settingsSchema": "",
+ "dataKeySettingsSchema": "{}\n",
+ "settingsDirective": "tb-chart-widget-settings",
+ "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\"}"
+ },
+ "tags": null
+}
\ No newline at end of file
diff --git a/application/src/main/data/json/system/widget_types/timeseries_table.json b/application/src/main/data/json/system/widget_types/timeseries_table.json
index d267d42763..767f6e8c33 100644
--- a/application/src/main/data/json/system/widget_types/timeseries_table.json
+++ b/application/src/main/data/json/system/widget_types/timeseries_table.json
@@ -11,7 +11,7 @@
"resources": [],
"templateHtml": "\n",
"templateCss": "",
- "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onLatestDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n ignoreDataUpdateOnIntervalTick: true,\n hasAdditionalLatestDataKeys: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries', units: '°C', decimals: 0 }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
+ "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.timeseriesTableWidget.onLatestDataUpdated();\n}\n\nself.onEditModeChanged = function() {\n self.ctx.$scope.timeseriesTableWidget.onEditModeChanged();\n}\n\nself.typeParameters = function() {\n return {\n ignoreDataUpdateOnIntervalTick: true,\n hasAdditionalLatestDataKeys: true,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries', units: '°C', decimals: 0 }];\n }\n };\n}\n\nself.actionSources = function() {\n return {\n 'actionCellButton': {\n name: 'widget-action.action-cell-button',\n multiple: true,\n hasShowCondition: true\n },\n 'rowClick': {\n name: 'widget-action.row-click',\n multiple: false\n }\n };\n}\n\nself.onDestroy = function() {\n}\n",
"settingsSchema": "",
"dataKeySettingsSchema": "",
"latestDataKeySettingsSchema": "",
diff --git a/application/src/main/data/json/tenant/dashboards/gateways.json b/application/src/main/data/json/tenant/dashboards/gateways.json
index 4ccee86d5e..59e5672b53 100644
--- a/application/src/main/data/json/tenant/dashboards/gateways.json
+++ b/application/src/main/data/json/tenant/dashboards/gateways.json
@@ -1,8 +1,8 @@
{
"title": "ThingsBoard IoT Gateways",
- "image": null,
+ "image": "tb-image:Z2F0ZXdheS1kYXNoYm9hcmQucG5n:Z2F0ZXdheS1kYXNoYm9hcmQucG5n;data:image/png;base64,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",
"mobileHide": false,
- "mobileOrder": null,
+ "mobileOrder": 6,
"configuration": {
"description": "",
"widgets": {
@@ -136,7 +136,7 @@
{
"name": "active_connectors",
"type": "attribute",
- "label": "Active Connectors",
+ "label": "Enabled Connectors",
"color": "#3f51b5",
"settings": {
"columnWidth": "20%",
@@ -248,7 +248,7 @@
"type": "customPretty",
"customHtml": "
\r\n",
"customCss": ".add-entity-form {\r\n min-width: 400px !important;\r\n}\r\n\r\n.add-entity-form .boolean-value-input {\r\n padding-left: 5px;\r\n}\r\n\r\n.add-entity-form .boolean-value-input .checkbox-label {\r\n margin-bottom: 8px;\r\n color: rgba(0,0,0,0.54);\r\n font-size: 12px;\r\n}\r\n\r\n.relations-list .header {\r\n padding-right: 5px;\r\n padding-bottom: 5px;\r\n padding-left: 5px;\r\n}\r\n\r\n.relations-list .header .cell {\r\n padding-right: 5px;\r\n padding-left: 5px;\r\n font-size: 12px;\r\n font-weight: 700;\r\n color: rgba(0, 0, 0, .54);\r\n white-space: nowrap;\r\n}\r\n\r\n.relations-list .mat-form-field-infix {\r\n width: auto !important;\r\n}\r\n\r\n.relations-list .body {\r\n padding-right: 5px;\r\n padding-bottom: 15px;\r\n padding-left: 5px;\r\n}\r\n\r\n.relations-list .body .row {\r\n padding-top: 5px;\r\n}\r\n\r\n.relations-list .body .cell {\r\n padding-right: 5px;\r\n padding-left: 5px;\r\n}\r\n\r\n.relations-list .body .md-button {\r\n margin: 0;\r\n}\r\n\r\n",
- "customFunction": "let $injector = widgetContext.$scope.$injector;\r\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\r\nlet assetService = $injector.get(widgetContext.servicesMap.get('assetService'));\r\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\r\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\r\nlet entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService'));\r\nlet userSettingsService = $injector.get(widgetContext.servicesMap.get('userSettingsService'));\r\n\r\nopenAddEntityDialog();\r\n\r\nfunction openAddEntityDialog() {\r\n customDialog.customDialog(htmlTemplate, AddEntityDialogController).subscribe();\r\n}\r\n\r\nfunction AddEntityDialogController(instance) {\r\n let vm = instance;\r\n let userSettings;\r\n userSettingsService.loadUserSettings().subscribe(settings=> {\r\n userSettings = settings;\r\n if (!userSettings.createdGatewaysCount) userSettings.createdGatewaysCount = 0;\r\n });\r\n \r\n\r\n vm.addEntityFormGroup = vm.fb.group({\r\n entityName: ['', [vm.validators.required]],\r\n entityType: ['DEVICE'],\r\n entityLabel: [''],\r\n type: ['', [vm.validators.required]],\r\n });\r\n\r\n vm.cancel = function() {\r\n vm.dialogRef.close(null);\r\n };\r\n\r\n\r\n vm.save = function($event) {\r\n vm.addEntityFormGroup.markAsPristine();\r\n saveEntityObservable().subscribe(\r\n function (device) {\r\n widgetContext.updateAliases();\r\n userSettingsService.putUserSettings({ createdGatewaysCount: ++userSettings.createdGatewaysCount }).subscribe(_=>{\r\n });\r\n vm.dialogRef.close(null);\r\n openCommandDialog(device, $event);\r\n }\r\n );\r\n };\r\n \r\n function openCommandDialog(device, $event) {\r\n vm.device = device;\r\n let openCommandAction = widgetContext.actionsApi.getActionDescriptors(\"actionCellButton\").find(action => action.name == \"Docker commands\");\r\n widgetContext.actionsApi.handleWidgetAction($event, openCommandAction, device.id, device.name, {newDevice: true});\r\n setTimeout(function() {\r\n document.querySelector(\".dashboard-state-dialog .mat-mdc-button-touch-target\").addEventListener('click', goToConfigState);\r\n document.querySelector(\".dashboard-state-dialog .mat-mdc-button.mat-primary\").addEventListener('click', goToConfigState);\r\n }, 500);\r\n }\r\n\r\n \r\n function goToConfigState() {\r\n document.querySelector(\".dashboard-state-dialog .mat-mdc-button-touch-target\").removeEventListener('click', goToConfigState);\r\n document.querySelector(\".dashboard-state-dialog .mat-mdc-button.mat-primary\").removeEventListener('click', goToConfigState);\r\n const stateParams = {};\r\n stateParams.entityId = vm.device.id;\r\n stateParams.entityName = vm.device.name;\r\n const newStateParams = {\r\n targetEntityParamName: 'default',\r\n new_gateway: {\r\n entityId: vm.device.id,\r\n entityName: vm.device.name\r\n }\r\n }\r\n const params = {...stateParams, ...newStateParams};\r\n widgetContext.stateController.openState('gateway_details', params, false);\r\n }\r\n\r\n function saveEntityObservable() {\r\n const formValues = vm.addEntityFormGroup.value;\r\n let entity = {\r\n name: formValues.entityName,\r\n type: formValues.type,\r\n label: formValues.entityLabel,\r\n additionalInfo: {\r\n gateway: true\r\n }\r\n };\r\n return deviceService.saveDevice(entity);\r\n }\r\n}\r\n",
+ "customFunction": "let $injector = widgetContext.$scope.$injector;\r\nlet customDialog = $injector.get(widgetContext.servicesMap.get('customDialog'));\r\nlet assetService = $injector.get(widgetContext.servicesMap.get('assetService'));\r\nlet deviceService = $injector.get(widgetContext.servicesMap.get('deviceService'));\r\nlet attributeService = $injector.get(widgetContext.servicesMap.get('attributeService'));\r\nlet entityRelationService = $injector.get(widgetContext.servicesMap.get('entityRelationService'));\r\nlet userSettingsService = $injector.get(widgetContext.servicesMap.get('userSettingsService'));\r\n\r\nopenAddEntityDialog();\r\n\r\nfunction openAddEntityDialog() {\r\n customDialog.customDialog(htmlTemplate, AddEntityDialogController).subscribe();\r\n}\r\n\r\nfunction AddEntityDialogController(instance) {\r\n let vm = instance;\r\n let userSettings;\r\n userSettingsService.loadUserSettings().subscribe(settings=> {\r\n userSettings = settings;\r\n if (!userSettings.createdGatewaysCount) userSettings.createdGatewaysCount = 0;\r\n });\r\n \r\n\r\n vm.addEntityFormGroup = vm.fb.group({\r\n entityName: ['', [vm.validators.required]],\r\n entityType: ['DEVICE'],\r\n entityLabel: [''],\r\n type: ['', [vm.validators.required]],\r\n });\r\n\r\n vm.cancel = function() {\r\n vm.dialogRef.close(null);\r\n };\r\n\r\n\r\n vm.save = function($event) {\r\n vm.addEntityFormGroup.markAsPristine();\r\n saveEntityObservable().subscribe(\r\n function (device) {\r\n widgetContext.updateAliases();\r\n userSettingsService.putUserSettings({ createdGatewaysCount: ++userSettings.createdGatewaysCount }).subscribe(_=>{\r\n });\r\n vm.dialogRef.close(null);\r\n openCommandDialog(device, $event);\r\n }\r\n );\r\n };\r\n \r\n function openCommandDialog(device, $event) {\r\n vm.device = device;\r\n let openCommandAction = widgetContext.actionsApi.getActionDescriptors(\"actionCellButton\").find(action => action.name == \"Docker commands\");\r\n widgetContext.actionsApi.handleWidgetAction($event, openCommandAction, device.id, device.name, {newDevice: true});\r\n goToConfigState();\r\n }\r\n\r\n \r\n function goToConfigState() {\r\n const stateParams = {};\r\n stateParams.entityId = vm.device.id;\r\n stateParams.entityName = vm.device.name;\r\n const newStateParams = {\r\n targetEntityParamName: 'default',\r\n new_gateway: {\r\n entityId: vm.device.id,\r\n entityName: vm.device.name\r\n }\r\n }\r\n const params = {...stateParams, ...newStateParams};\r\n widgetContext.stateController.openState('gateway_details', params, false);\r\n }\r\n\r\n function saveEntityObservable() {\r\n const formValues = vm.addEntityFormGroup.value;\r\n let entity = {\r\n name: formValues.entityName,\r\n type: formValues.type,\r\n label: formValues.entityLabel,\r\n additionalInfo: {\r\n gateway: true\r\n }\r\n };\r\n return deviceService.saveDevice(entity);\r\n }\r\n}\r\n",
"customResources": [],
"openInSeparateDialog": false,
"openInPopover": false,
@@ -567,7 +567,8 @@
"padding": "8px",
"settings": {
"useMarkdownTextFunction": true,
- "markdownTextFunction": "var blockData = '';\nvar connectorsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action=>action.name==\"Connectors\");\nvar logsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action=>action.name==\"Logs\");\nfunction generateMatHeader(index) {\n if( index !== undefined && index > -1) {\n return ``\n } else {\n return \"\" \n }\n}\nfunction createDataBlock(value, label, dividerStyle, mobile, index) {\n blockData += `\n \n \n \n ${generateMatHeader(index)}\n ${label}\n \n ${value}\n `;\n}\ncreateDataBlock(data[0].Status, \"Status\", data[0].Status === \"Active\"? 'divider-green' : 'divider-red');\ncreateDataBlock(data[0].Name, \"Gateway Name\", '', ctx.isMobile);\ncreateDataBlock(data[0].Type, \"Gateway Type\", '');\ncreateDataBlock(\n `${(data[1]?data[1].count:0)} `\n + \" | \" + \n `${(data[2]?data[2][\"count 2\"]:0)} `\n , \"Devices (Active | Inactive)\", '');\ncreateDataBlock(\n `${(data[0].active_connectors?JSON.parse(data[0].active_connectors).length:0)} `\n + \" | \" + \n `${(data[0].inactive_connectors?JSON.parse(data[0].inactive_connectors).length:0)} `\n , \"Connectors (Active | Inactive)\", '', '', connectorsIndex);\ncreateDataBlock(data[0].ALL_ERRORS_COUNT || 0, \"Errors\", (data[0].ALL_ERRORS_COUNT || 0) === 0 ? 'divider-green' : 'divider-red', '', logsIndex);\nreturn `${blockData}
`;",
+ "markdownTextPattern": "# Markdown/HTML card \\n - **Current entity**: **${entityName}**. \\n - **Current value**: **${Random}**.",
+ "markdownTextFunction": "var blockData = '';\nvar connectorsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action=>action.name==\"Connectors\");\nvar logsIndex = ctx.actionsApi.getActionDescriptors('elementClick').findIndex(action=>action.name==\"Logs\");\nfunction generateMatHeader(index) {\n if( index !== undefined && index > -1) {\n return ``\n } else {\n return \"\" \n }\n}\nfunction createDataBlock(value, label, dividerStyle, mobile, index) {\n blockData += `\n \n \n \n ${generateMatHeader(index)}\n ${label}\n \n ${value}\n `;\n}\ncreateDataBlock(data[0].Status, \"Status\", data[0].Status === \"Active\"? 'divider-green' : 'divider-red');\ncreateDataBlock(data[0].Name, \"Gateway Name\", '', ctx.isMobile);\ncreateDataBlock(data[0].Type, \"Gateway Type\", '');\ncreateDataBlock(\n `${(data[1]?data[1].count:0)} `\n + \" | \" + \n `${(data[2]?data[2][\"count 2\"]:0)} `\n , \"Devices (Active | Inactive)\", '');\ncreateDataBlock(\n `${(data[0].active_connectors?JSON.parse(data[0].active_connectors).length:0)} `\n + \" | \" + \n `${(data[0].inactive_connectors?JSON.parse(data[0].inactive_connectors).length:0)} `\n , \"Connectors (Enabled | Disabled)\", '', '', connectorsIndex);\ncreateDataBlock(data[0].ALL_ERRORS_COUNT || 0, \"Errors\", (data[0].ALL_ERRORS_COUNT || 0) === 0 ? 'divider-green' : 'divider-red', '', logsIndex);\nreturn `${blockData}
`;",
"applyDefaultMarkdownStyle": false,
"markdownCss": ".divider {\n position: absolute;\n width: 3px;\n top: 8px;\n border-radius: 2px;\n bottom: 8px;\n border: 1px solid rgba(31, 70, 144, 1);\n background-color: rgba(31, 70, 144, 1);\n left: 10px;\n}\n.divider-green .divider {\n border: 1px solid rgb(25,128,56);\n background-color: rgb(25,128,56);\n}\n\n.divider-green .mat-mdc-card-content {\n color: rgb(25,128,56);\n}\n\n.divider-red .divider {\n border: 1px solid rgb(203,37,48);\n background-color: rgb(203,37,48);\n}\n\n.divider-red .mat-mdc-card-content {\n color: rgb(203,37,48);\n}\n\n.mdc-card {\n position: relative;\n padding-left: 10px;\n margin-bottom: 1px;\n}\n\n.mat-mdc-card-subtitle {\n font-weight: 400;\n font-size: 12px;\n}\n\n.mat-mdc-card-header {\n padding: 8px 16px 0;\n}\n\n.mat-mdc-card-content:last-child {\n padding-bottom: 8px;\n font-size: 16px;\n}\n\n.cards-container {\n height: calc(100% - 1px);\n justify-content: stretch;\n align-items: center;\n margin-bottom: 1px;\n}\n\n::ng-deep.tb-home-widget-link > div {\n flex-grow: 1;\n cursor: pointer;\n}\n\n .tb-home-widget-link {\n width: 100%;\n }\n\n .tb-home-widget-link:hover::after{\n color: inherit;\n }\n \n .tb-home-widget-link::after{\n content: 'arrow_forward';\n display: inline-block;\n transform: rotate(315deg);\n font-family: 'Material Icons';\n font-weight: normal;\n font-style: normal;\n font-size: 18px;\n color: rgba(0, 0, 0, 0.12);\n vertical-align: bottom;\n margin-left: 6px;\n}"
},
@@ -5218,7 +5219,7 @@
"widgets": {
"02a05113-d878-b086-328e-75bd6f02b2bb": {
"sizeX": 24,
- "sizeY": 11,
+ "sizeY": 12,
"row": 0,
"col": 0
}
@@ -5230,7 +5231,7 @@
"backgroundSizeMode": "100%",
"autoFillHeight": true,
"backgroundImageUrl": null,
- "mobileAutoFillHeight": false,
+ "mobileAutoFillHeight": true,
"mobileRowHeight": 70,
"outerMargin": true
}
@@ -6741,4 +6742,4 @@
},
"externalId": null,
"name": "Gateway"
-}
+}
\ No newline at end of file
diff --git a/application/src/main/data/upgrade/3.6.4/schema_update.sql b/application/src/main/data/upgrade/3.6.4/schema_update.sql
index d7d8887d13..af354acf62 100644
--- a/application/src/main/data/upgrade/3.6.4/schema_update.sql
+++ b/application/src/main/data/upgrade/3.6.4/schema_update.sql
@@ -14,6 +14,23 @@
-- limitations under the License.
--
+-- UPDATE PUBLIC CUSTOMERS START
+
+DO
+$$
+ BEGIN
+ IF NOT EXISTS (
+ SELECT FROM information_schema.columns
+ WHERE table_name = 'customer' AND column_name = 'is_public'
+ ) THEN
+ ALTER TABLE customer ADD COLUMN is_public boolean DEFAULT false;
+ UPDATE customer SET is_public = true WHERE title = 'Public';
+ END IF;
+ END;
+$$;
+
+-- UPDATE PUBLIC CUSTOMERS END
+
-- create new attribute_kv table schema
DO
$$
@@ -90,13 +107,7 @@ BEGIN
SELECT COUNT(*) INTO row_num_old FROM attribute_kv_old;
SELECT COUNT(*) INTO row_num FROM attribute_kv;
RAISE NOTICE 'Migrated % of % rows', row_num, row_num_old;
-
- IF row_num != 0 THEN
- DROP TABLE IF EXISTS attribute_kv_old;
- ELSE
- RAISE EXCEPTION 'Table attribute_kv is empty';
- END IF;
-
+ DROP TABLE IF EXISTS attribute_kv_old;
CREATE INDEX IF NOT EXISTS idx_attribute_kv_by_key_and_last_update_ts ON attribute_kv(entity_id, attribute_key, last_update_ts desc);
END IF;
EXCEPTION
diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java
index 650a396dc5..a16e6122f5 100644
--- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java
+++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleChainActorMessageProcessor.java
@@ -103,7 +103,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor ruleNodeList = service.getRuleChainNodes(tenantId, entityId);
- log.trace("[{}][{}] Starting rule chain with {} nodes", tenantId, entityId, ruleNodeList.size());
+ log.debug("[{}][{}] 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);
@@ -124,7 +124,7 @@ public class RuleChainActorMessageProcessor extends ComponentMsgProcessor ruleNodeList = service.getRuleChainNodes(tenantId, entityId);
- log.trace("[{}][{}] Updating rule chain with {} nodes", tenantId, entityId, ruleNodeList.size());
+ log.debug("[{}][{}] Updating rule chain with {} nodes", tenantId, entityId, ruleNodeList.size());
for (RuleNode ruleNode : ruleNodeList) {
RuleNodeCtx existing = nodeActors.get(ruleNode.getId());
if (existing == null) {
diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
index eda8d3e89d..6c90e6e27d 100644
--- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
+++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java
@@ -72,12 +72,11 @@ public class ThingsboardSecurityConfiguration {
public static final String JWT_TOKEN_HEADER_PARAM_V2 = "Authorization";
public static final String JWT_TOKEN_QUERY_PARAM = "token";
- public static final String WEBJARS_ENTRY_POINT = "/webjars/**";
public static final String DEVICE_API_ENTRY_POINT = "/api/v1/**";
public static final String FORM_BASED_LOGIN_ENTRY_POINT = "/api/auth/login";
public static final String PUBLIC_LOGIN_ENTRY_POINT = "/api/auth/login/public";
public static final String TOKEN_REFRESH_ENTRY_POINT = "/api/auth/token";
- protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[] {"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**", "/api/images/public/**"};
+ protected static final String[] NON_TOKEN_BASED_AUTH_ENTRY_POINTS = new String[]{"/index.html", "/assets/**", "/static/**", "/api/noauth/**", "/webjars/**", "/api/license/**", "/api/images/public/**"};
public static final String TOKEN_BASED_AUTH_ENTRY_POINT = "/api/**";
public static final String WS_ENTRY_POINT = "/api/ws/**";
public static final String MAIL_OAUTH2_PROCESSING_ENTRY_POINT = "/api/admin/mail/oauth2/code";
@@ -153,7 +152,7 @@ public class ThingsboardSecurityConfiguration {
protected JwtTokenAuthenticationProcessingFilter buildJwtTokenAuthenticationProcessingFilter() throws Exception {
List pathsToSkip = new ArrayList<>(Arrays.asList(NON_TOKEN_BASED_AUTH_ENTRY_POINTS));
pathsToSkip.addAll(Arrays.asList(WS_ENTRY_POINT, TOKEN_REFRESH_ENTRY_POINT, FORM_BASED_LOGIN_ENTRY_POINT,
- PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, WEBJARS_ENTRY_POINT, MAIL_OAUTH2_PROCESSING_ENTRY_POINT,
+ PUBLIC_LOGIN_ENTRY_POINT, DEVICE_API_ENTRY_POINT, MAIL_OAUTH2_PROCESSING_ENTRY_POINT,
DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT));
SkipPathRequestMatcher matcher = new SkipPathRequestMatcher(pathsToSkip, TOKEN_BASED_AUTH_ENTRY_POINT);
JwtTokenAuthenticationProcessingFilter filter
@@ -210,18 +209,17 @@ public class ThingsboardSecurityConfiguration {
.exceptionHandling(config -> {})
.sessionManagement(config -> config.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(config -> config
- .requestMatchers(WEBJARS_ENTRY_POINT).permitAll() // Webjars
- .requestMatchers(DEVICE_API_ENTRY_POINT).permitAll() // Device HTTP Transport API
- .requestMatchers(FORM_BASED_LOGIN_ENTRY_POINT).permitAll() // Login end-point
- .requestMatchers(PUBLIC_LOGIN_ENTRY_POINT).permitAll() // Public login end-point
- .requestMatchers(TOKEN_REFRESH_ENTRY_POINT).permitAll() // Token refresh end-point
- .requestMatchers(MAIL_OAUTH2_PROCESSING_ENTRY_POINT).permitAll() // Mail oauth2 code processing url
- .requestMatchers(DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT).permitAll() // Device connectivity certificate (public)
- .requestMatchers(NON_TOKEN_BASED_AUTH_ENTRY_POINTS).permitAll() // static resources, user activation and password reset end-points
- .requestMatchers(WS_ENTRY_POINT).permitAll() // Protected WebSocket API End-points
- .requestMatchers(TOKEN_BASED_AUTH_ENTRY_POINT).authenticated()) // Protected API End-points
- .formLogin(form -> form
- .loginPage("/login").permitAll())
+ .requestMatchers(NON_TOKEN_BASED_AUTH_ENTRY_POINTS).permitAll() // static resources, user activation and password reset end-points (webjars included)
+ .requestMatchers(
+ DEVICE_API_ENTRY_POINT, // Device HTTP Transport API
+ FORM_BASED_LOGIN_ENTRY_POINT, // Login end-point
+ PUBLIC_LOGIN_ENTRY_POINT, // Public login end-point
+ TOKEN_REFRESH_ENTRY_POINT, // Token refresh end-point
+ MAIL_OAUTH2_PROCESSING_ENTRY_POINT, // Mail oauth2 code processing url
+ DEVICE_CONNECTIVITY_CERTIFICATE_DOWNLOAD_ENTRY_POINT, // Device connectivity certificate (public)
+ WS_ENTRY_POINT).permitAll() // Protected WebSocket API End-points
+ .requestMatchers(TOKEN_BASED_AUTH_ENTRY_POINT).authenticated() // Protected API End-points
+ .anyRequest().permitAll())
.exceptionHandling(config -> config.accessDeniedHandler(restAccessDeniedHandler))
.addFilterBefore(buildRestLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(buildRestPublicLoginProcessingFilter(), UsernamePasswordAuthenticationFilter.class)
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 2eecd61798..33e2ddee01 100644
--- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java
+++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java
@@ -133,7 +133,10 @@ public class ThingsboardInstallService {
case "3.6.4":
log.info("Upgrading ThingsBoard from version 3.6.4 to 3.7.0 ...");
databaseEntitiesUpgradeService.upgradeDatabase("3.6.4");
+ dataUpdateService.updateData("3.6.4");
+ entityDatabaseSchemaService.createCustomerTitleUniqueConstraintIfNotExists();
systemDataLoaderService.updateDefaultNotificationConfigs(false);
+ systemDataLoaderService.updateJwtSettings();
//TODO DON'T FORGET to update switch statement in the CacheCleanupService if you need to clear the cache
break;
default:
diff --git a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java
index 0c3146a6c6..38f44898ee 100644
--- a/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java
+++ b/application/src/main/java/org/thingsboard/server/service/device/DeviceBulkImportService.java
@@ -33,7 +33,6 @@ import org.thingsboard.server.common.data.StringUtils;
import org.thingsboard.server.common.data.device.credentials.BasicMqttCredentials;
import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MClientCredential;
import org.thingsboard.server.common.data.device.credentials.lwm2m.LwM2MSecurityMode;
-import org.thingsboard.server.common.data.device.data.DefaultDeviceConfiguration;
import org.thingsboard.server.common.data.device.data.DeviceData;
import org.thingsboard.server.common.data.device.data.PowerMode;
import org.thingsboard.server.common.data.device.data.SnmpDeviceTransportConfiguration;
@@ -68,6 +67,8 @@ import java.util.Set;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
+import static org.eclipse.leshan.core.LwM2m.Version.V1_0;
+
@Service
@TbCoreComponent
@RequiredArgsConstructor
@@ -256,7 +257,7 @@ public class DeviceBulkImportService extends AbstractBulkImportService {
Lwm2mDeviceProfileTransportConfiguration transportConfiguration = new Lwm2mDeviceProfileTransportConfiguration();
transportConfiguration.setBootstrap(Collections.emptyList());
- transportConfiguration.setClientLwM2mSettings(new OtherConfiguration(1, 1, 1, PowerMode.DRX, null, null, null, null, null));
+ transportConfiguration.setClientLwM2mSettings(new OtherConfiguration(1, 1, 1, PowerMode.DRX, null, null, null, null, null, V1_0.toString()));
transportConfiguration.setObserveAttr(new TelemetryMappingConfiguration(Collections.emptyMap(), Collections.emptySet(), Collections.emptySet(), Collections.emptySet(), Collections.emptyMap()));
DeviceProfileData deviceProfileData = new DeviceProfileData();
diff --git a/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperReprocessingService.java b/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperReprocessingService.java
index ae16f925a0..37a55c99d6 100644
--- a/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperReprocessingService.java
+++ b/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperReprocessingService.java
@@ -27,11 +27,11 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToHousekeeperService
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.TbQueueProducer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
import org.thingsboard.server.queue.housekeeper.HousekeeperConfig;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.queue.util.TbCoreComponent;
-import org.thingsboard.server.service.queue.consumer.QueueConsumerManager;
import javax.annotation.PreDestroy;
import java.util.LinkedHashSet;
diff --git a/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperService.java b/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperService.java
index 06e38d0160..eea92eb75b 100644
--- a/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperService.java
+++ b/application/src/main/java/org/thingsboard/server/service/housekeeper/HousekeeperService.java
@@ -27,13 +27,13 @@ import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor;
import org.thingsboard.server.gen.transport.TransportProtos.ToHousekeeperServiceMsg;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
import org.thingsboard.server.queue.housekeeper.HousekeeperConfig;
import org.thingsboard.server.queue.provider.TbCoreQueueFactory;
import org.thingsboard.server.queue.util.AfterStartUp;
import org.thingsboard.server.queue.util.TbCoreComponent;
import org.thingsboard.server.service.housekeeper.processor.HousekeeperTaskProcessor;
import org.thingsboard.server.service.housekeeper.stats.HousekeeperStatsService;
-import org.thingsboard.server.service.queue.consumer.QueueConsumerManager;
import javax.annotation.PreDestroy;
import java.util.List;
diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
index 8c86c16edb..bdd0494443 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java
@@ -19,13 +19,17 @@ import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
+import jakarta.annotation.Nullable;
+import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
import lombok.Getter;
+import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.RandomStringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;
@@ -81,6 +85,7 @@ import org.thingsboard.server.common.data.rule.RuleChainType;
import org.thingsboard.server.common.data.security.Authority;
import org.thingsboard.server.common.data.security.DeviceCredentials;
import org.thingsboard.server.common.data.security.UserCredentials;
+import org.thingsboard.server.common.data.security.model.JwtSettings;
import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileData;
import org.thingsboard.server.common.data.tenant.profile.TenantProfileQueueConfiguration;
@@ -100,14 +105,11 @@ import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.dao.tenant.TenantService;
import org.thingsboard.server.dao.timeseries.TimeseriesService;
import org.thingsboard.server.dao.user.UserService;
-import org.thingsboard.server.dao.widget.WidgetTypeService;
-import org.thingsboard.server.dao.widget.WidgetsBundleService;
import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService;
-import jakarta.annotation.Nullable;
-import jakarta.annotation.PostConstruct;
-import jakarta.annotation.PreDestroy;
+import java.nio.charset.StandardCharsets;
import java.util.Arrays;
+import java.util.Base64;
import java.util.Collections;
import java.util.List;
import java.util.TreeMap;
@@ -117,79 +119,51 @@ import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.thingsboard.server.common.data.DataConstants.DEFAULT_DEVICE_TYPE;
+import static org.thingsboard.server.service.security.auth.jwt.settings.DefaultJwtSettingsService.isSigningKeyDefault;
+import static org.thingsboard.server.service.security.auth.jwt.settings.DefaultJwtSettingsService.validateTokenSigningKeyLength;
@Service
@Profile("install")
@Slf4j
+@RequiredArgsConstructor
public class DefaultSystemDataLoaderService implements SystemDataLoaderService {
public static final String CUSTOMER_CRED = "customer";
public static final String ACTIVITY_STATE = "active";
- @Autowired
- private InstallScripts installScripts;
+ private final InstallScripts installScripts;
+ private final UserService userService;
+ private final AdminSettingsService adminSettingsService;
+ private final TenantService tenantService;
+ private final TenantProfileService tenantProfileService;
+ private final CustomerService customerService;
+ private final DeviceService deviceService;
+ private final DeviceProfileService deviceProfileService;
+ private final AttributesService attributesService;
+ private final DeviceCredentialsService deviceCredentialsService;
+ private final RuleChainService ruleChainService;
+ private final TimeseriesService tsService;
+ private final DeviceConnectivityConfiguration connectivityConfiguration;
+ private final QueueService queueService;
+ private final JwtSettingsService jwtSettingsService;
+ private final NotificationSettingsService notificationSettingsService;
+ private final NotificationTargetService notificationTargetService;
@Autowired
private BCryptPasswordEncoder passwordEncoder;
- @Autowired
- private UserService userService;
-
- @Autowired
- private AdminSettingsService adminSettingsService;
-
- @Autowired
- private WidgetTypeService widgetTypeService;
-
- @Autowired
- private WidgetsBundleService widgetsBundleService;
-
- @Autowired
- private TenantService tenantService;
-
- @Autowired
- private TenantProfileService tenantProfileService;
-
- @Autowired
- private CustomerService customerService;
-
- @Autowired
- private DeviceService deviceService;
-
- @Autowired
- private DeviceProfileService deviceProfileService;
-
- @Autowired
- private AttributesService attributesService;
-
- @Autowired
- private DeviceCredentialsService deviceCredentialsService;
-
- @Autowired
- private RuleChainService ruleChainService;
-
- @Autowired
- private TimeseriesService tsService;
-
- @Autowired
- private DeviceConnectivityConfiguration connectivityConfiguration;
-
@Value("${state.persistToTelemetry:false}")
@Getter
private boolean persistActivityToTelemetry;
- @Lazy
- @Autowired
- private QueueService queueService;
-
- @Autowired
- private JwtSettingsService jwtSettingsService;
-
- @Autowired
- private NotificationSettingsService notificationSettingsService;
-
- @Autowired
- private NotificationTargetService notificationTargetService;
+ @Value("${security.jwt.tokenExpirationTime:9000}")
+ private Integer tokenExpirationTime;
+ @Value("${security.jwt.refreshTokenExpTime:604800}")
+ private Integer refreshTokenExpTime;
+ @Value("${security.jwt.tokenIssuer:thingsboard.io}")
+ private String tokenIssuer;
+ @Value("${security.jwt.tokenSigningKey:thingsboardDefaultSigningKey}")
+ private String tokenSigningKey;
@Bean
protected BCryptPasswordEncoder passwordEncoder() {
@@ -295,7 +269,42 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService {
@Override
public void createRandomJwtSettings() throws Exception {
- jwtSettingsService.createRandomJwtSettings();
+ if (jwtSettingsService.getJwtSettings() == null) {
+ log.info("Creating JWT admin settings...");
+ var jwtSettings = new JwtSettings(this.tokenExpirationTime, this.refreshTokenExpTime, this.tokenIssuer, this.tokenSigningKey);
+ if (isSigningKeyDefault(jwtSettings) || !validateTokenSigningKeyLength(jwtSettings)) {
+ jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(
+ RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8)));
+ }
+ jwtSettingsService.saveJwtSettings(jwtSettings);
+ } else {
+ log.info("Skip creating JWT admin settings because they already exist.");
+ }
+ }
+
+ @Override
+ public void updateJwtSettings() {
+ JwtSettings jwtSettings = jwtSettingsService.getJwtSettings();
+ boolean invalidSignKey = false;
+ String warningMessage = null;
+
+ if (isSigningKeyDefault(jwtSettings)) {
+ warningMessage = "The platform is using the default JWT Signing Key, which is a security risk.";
+ invalidSignKey = true;
+ } else if (!validateTokenSigningKeyLength(jwtSettings)) {
+ warningMessage = "The JWT Signing Key is shorter than 512 bits, which is a security risk.";
+ invalidSignKey = true;
+ }
+
+ if (invalidSignKey) {
+ log.warn("WARNING: {}. A new JWT Signing Key has been added automatically. " +
+ "You can change the JWT Signing Key using the Web UI: " +
+ "Navigate to \"System settings -> Security settings\" while logged in as a System Administrator.", warningMessage);
+
+ jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(
+ RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8)));
+ jwtSettingsService.saveJwtSettings(jwtSettings);
+ }
}
@Override
diff --git a/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java
index 5d7988d9e5..68f19e7a02 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/EntityDatabaseSchemaService.java
@@ -21,4 +21,6 @@ public interface EntityDatabaseSchemaService extends DatabaseSchemaService {
void createOrUpdateViewsAndFunctions() throws Exception;
+ void createCustomerTitleUniqueConstraintIfNotExists();
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java
index 9058db4172..3ba938bd90 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/SqlAbstractDatabaseSchemaService.java
@@ -84,13 +84,17 @@ public abstract class SqlAbstractDatabaseSchemaService implements DatabaseSchema
}
protected void executeQuery(String query) {
+ executeQuery(query, null);
+ }
+
+ protected void executeQuery(String query, String logQuery) {
+ logQuery = logQuery != null ? logQuery : query;
try (Connection conn = DriverManager.getConnection(dbUrl, dbUserName, dbPassword)) {
conn.createStatement().execute(query); //NOSONAR, ignoring because method used to execute thingsboard database upgrade script
- log.info("Successfully executed query: {}", query);
+ log.info("Successfully executed query: {}", logQuery);
Thread.sleep(5000);
} catch (InterruptedException | SQLException e) {
- log.error("Failed to execute query: {} due to: {}", query, e.getMessage());
- throw new RuntimeException("Failed to execute query: " + query, e);
+ throw new RuntimeException("Failed to execute query: " + logQuery, e);
}
}
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
index b6412d0b9a..5e1357a48e 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/SqlEntityDatabaseSchemaService.java
@@ -52,4 +52,11 @@ public class SqlEntityDatabaseSchemaService extends SqlAbstractDatabaseSchemaSer
log.info("Installing SQL DataBase schema views and functions: " + SCHEMA_VIEWS_AND_FUNCTIONS_SQL);
executeQueryFromFile(SCHEMA_VIEWS_AND_FUNCTIONS_SQL);
}
+
+ @Override
+ public void createCustomerTitleUniqueConstraintIfNotExists() {
+ executeQuery("DO $$ BEGIN IF NOT EXISTS(SELECT 1 FROM pg_constraint WHERE conname = 'customer_title_unq_key') THEN " +
+ "ALTER TABLE customer ADD CONSTRAINT customer_title_unq_key UNIQUE(tenant_id, title); END IF; END; $$;",
+ "create 'customer_title_unq_key' constraint if it doesn't already exist!");
+ }
}
diff --git a/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java
index 7c05ff620f..71c829ee11 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/SystemDataLoaderService.java
@@ -25,6 +25,8 @@ public interface SystemDataLoaderService {
void createRandomJwtSettings() throws Exception;
+ void updateJwtSettings() throws Exception;
+
void createOAuth2Templates() throws Exception;
void loadSystemWidgets() throws Exception;
diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java
index 5a1d2fe38d..7fd92bc5c6 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultCacheCleanupService.java
@@ -27,7 +27,6 @@ import org.springframework.stereotype.Service;
import java.util.Objects;
import java.util.Optional;
-import static org.thingsboard.server.common.data.CacheConstants.ATTRIBUTES_CACHE;
import static org.thingsboard.server.common.data.CacheConstants.RESOURCE_INFO_CACHE;
import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTINGS_CACHE;
diff --git a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
index 3f8fa76fd9..9626190f1b 100644
--- a/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
+++ b/application/src/main/java/org/thingsboard/server/service/install/update/DefaultDataUpdateService.java
@@ -26,22 +26,27 @@ import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.common.data.AdminSettings;
+import org.thingsboard.server.common.data.Customer;
import org.thingsboard.server.common.data.alarm.AlarmSeverity;
import org.thingsboard.server.common.data.id.RuleNodeId;
import org.thingsboard.server.common.data.id.TenantId;
import org.thingsboard.server.common.data.page.PageDataIterable;
import org.thingsboard.server.common.data.query.DynamicValue;
import org.thingsboard.server.common.data.query.FilterPredicateValue;
+import org.thingsboard.server.dao.customer.CustomerDao;
+import org.thingsboard.server.dao.customer.CustomerService;
import org.thingsboard.server.dao.device.DeviceConnectivityConfiguration;
import org.thingsboard.server.dao.rule.RuleChainService;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.dao.sql.JpaExecutorService;
+import org.thingsboard.server.dao.tenant.TenantProfileService;
import org.thingsboard.server.service.component.ComponentDiscoveryService;
import org.thingsboard.server.service.component.RuleNodeClassInfo;
import org.thingsboard.server.utils.TbNodeUpgradeUtils;
import java.util.ArrayList;
import java.util.List;
+import java.util.Optional;
import java.util.concurrent.ExecutionException;
@Service
@@ -67,6 +72,15 @@ public class DefaultDataUpdateService implements DataUpdateService {
@Autowired
DeviceConnectivityConfiguration connectivityConfiguration;
+ @Autowired
+ private CustomerDao customerDao;
+
+ @Autowired
+ private CustomerService customerService;
+
+ @Autowired
+ private TenantProfileService tenantProfileService;
+
@Override
public void updateData(String fromVersion) throws Exception {
switch (fromVersion) {
@@ -74,11 +88,79 @@ public class DefaultDataUpdateService implements DataUpdateService {
log.info("Updating data from version 3.6.0 to 3.6.1 ...");
migrateDeviceConnectivity();
break;
+ case "3.6.4":
+ log.info("Updating data from version 3.6.4 to 3.7.0 ...");
+ updateCustomersWithTheSameTitle();
+ updateMaxRuleNodeExecsPerMessage();
+ break;
default:
throw new RuntimeException("Unable to update data, unsupported fromVersion: " + fromVersion);
}
}
+ private void updateMaxRuleNodeExecsPerMessage() {
+ var tenantProfiles = new PageDataIterable<>(
+ link -> tenantProfileService.findTenantProfiles(TenantId.SYS_TENANT_ID, link), DEFAULT_PAGE_SIZE);
+ tenantProfiles.forEach(tenantProfile -> {
+ var configurationOpt = tenantProfile.getProfileConfiguration();
+ configurationOpt.ifPresent(configuration -> {
+ if (configuration.getMaxRuleNodeExecsPerMessage() == 0) {
+ configuration.setMaxRuleNodeExecutionsPerMessage(1000);
+ try {
+ tenantProfileService.saveTenantProfile(TenantId.SYS_TENANT_ID, tenantProfile);
+ } catch (Exception e) {
+ log.error("Failed to update tenant profile with id: {} due to: ", tenantProfile.getId(), e);
+ }
+ }
+ });
+ });
+ }
+
+ private void updateCustomersWithTheSameTitle() {
+ var customers = new ArrayList();
+ new PageDataIterable<>(pageLink ->
+ customerDao.findCustomersWithTheSameTitle(pageLink), DEFAULT_PAGE_SIZE
+ ).forEach(customers::add);
+ if (customers.isEmpty()) {
+ return;
+ }
+ var firstCustomer = customers.get(0);
+ var titleToDeduplicate = firstCustomer.getTitle();
+ var tenantIdToDeduplicate = firstCustomer.getTenantId();
+ int duplicateCounter = 1;
+
+ for (int i = 1; i < customers.size(); i++) {
+ var currentCustomer = customers.get(i);
+ if (currentCustomer.getTitle().equals(titleToDeduplicate) && currentCustomer.getTenantId().equals(tenantIdToDeduplicate)) {
+ duplicateCounter++;
+ String currentTitle = currentCustomer.getTitle();
+ String newTitle = currentTitle + " " + duplicateCounter;
+ try {
+ Optional customerOpt = customerService.findCustomerByTenantIdAndTitle(tenantIdToDeduplicate, newTitle);
+ if (customerOpt.isPresent()) {
+ // fallback logic: customer with title 'currentTitle + " " + duplicateCounter;' might be another duplicate.
+ newTitle = currentTitle + "_" + currentCustomer.getId();
+ }
+ } catch (Exception e) {
+ log.trace("Failed to find customer with title due to: ", e);
+ // fallback logic: customer with title 'currentTitle + " " + duplicateCounter;' might be another duplicate.
+ newTitle = currentTitle + "_" + currentCustomer.getId();
+ }
+ currentCustomer.setTitle(newTitle);
+ try {
+ customerService.saveCustomer(currentCustomer);
+ } catch (Exception e) {
+ log.error("[{}] Failed to update customer with id and title: {}, oldTitle: {}, due to: ",
+ currentCustomer.getTenantId(), newTitle, currentTitle, e);
+ }
+ continue;
+ }
+ titleToDeduplicate = currentCustomer.getTitle();
+ tenantIdToDeduplicate = currentCustomer.getTenantId();
+ duplicateCounter = 1;
+ }
+ }
+
private void migrateDeviceConnectivity() {
if (adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "connectivity") == null) {
AdminSettings connectivitySettings = new AdminSettings();
diff --git a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java
index 29db1d7d76..257dfa14b1 100644
--- a/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java
+++ b/application/src/main/java/org/thingsboard/server/service/mail/DefaultMailService.java
@@ -16,12 +16,13 @@
package org.thingsboard.server.service.mail;
import com.fasterxml.jackson.databind.JsonNode;
+import com.google.common.util.concurrent.Futures;
import freemarker.template.Configuration;
import freemarker.template.Template;
-import jakarta.xml.bind.DatatypeConverter;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.NestedRuntimeException;
@@ -31,29 +32,36 @@ import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.springframework.ui.freemarker.FreeMarkerTemplateUtils;
+import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.rule.engine.api.MailService;
import org.thingsboard.rule.engine.api.TbEmail;
+import org.thingsboard.server.cache.limits.RateLimitService;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.ApiFeature;
import org.thingsboard.server.common.data.ApiUsageRecordKey;
import org.thingsboard.server.common.data.ApiUsageRecordState;
import org.thingsboard.server.common.data.ApiUsageStateValue;
import org.thingsboard.server.common.data.StringUtils;
+import org.thingsboard.server.common.data.exception.RateLimitExceededException;
import org.thingsboard.server.common.data.exception.ThingsboardErrorCode;
import org.thingsboard.server.common.data.exception.ThingsboardException;
import org.thingsboard.server.common.data.id.CustomerId;
import org.thingsboard.server.common.data.id.TenantId;
+import org.thingsboard.server.common.data.limit.LimitedApi;
import org.thingsboard.server.common.stats.TbApiUsageReportClient;
import org.thingsboard.server.dao.exception.IncorrectParameterException;
import org.thingsboard.server.dao.settings.AdminSettingsService;
import org.thingsboard.server.service.apiusage.TbApiUsageStateService;
import jakarta.annotation.PostConstruct;
+import jakarta.annotation.PreDestroy;
import jakarta.mail.internet.MimeMessage;
import java.io.ByteArrayInputStream;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@@ -76,13 +84,21 @@ public class DefaultMailService implements MailService {
private TbApiUsageStateService apiUsageStateService;
@Autowired
- private MailExecutorService mailExecutorService;
+ private MailSenderInternalExecutorService mailExecutorService;
@Autowired
private PasswordResetExecutorService passwordResetExecutorService;
@Autowired
- private TbMailContextComponent tbMailContextComponent;
+ private TbMailContextComponent ctx;
+
+ @Autowired
+ private RateLimitService rateLimitService;
+
+ @Value("${mail.per_tenant_rate_limits:}")
+ private String perTenantRateLimitConfig;
+
+ private final ScheduledExecutorService timeoutScheduler;
private TbMailSender mailSender;
@@ -95,6 +111,7 @@ public class DefaultMailService implements MailService {
this.freemarkerConfig = freemarkerConfig;
this.adminSettingsService = adminSettingsService;
this.apiUsageClient = apiUsageClient;
+ this.timeoutScheduler = Executors.newScheduledThreadPool(1, ThingsBoardThreadFactory.forName("mail-service-watchdog"));
}
@PostConstruct
@@ -102,12 +119,19 @@ public class DefaultMailService implements MailService {
updateMailConfiguration();
}
+ @PreDestroy
+ public void destroy() {
+ if (timeoutScheduler != null) {
+ timeoutScheduler.shutdownNow();
+ }
+ }
+
@Override
public void updateMailConfiguration() {
AdminSettings settings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, "mail");
if (settings != null) {
JsonNode jsonConfig = settings.getJsonValue();
- mailSender = new TbMailSender(tbMailContextComponent, jsonConfig);
+ mailSender = new TbMailSender(ctx, jsonConfig);
mailFrom = jsonConfig.get("mailFrom").asText();
timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT);
} else {
@@ -122,7 +146,7 @@ public class DefaultMailService implements MailService {
@Override
public void sendTestMail(JsonNode jsonConfig, String email) throws ThingsboardException {
- TbMailSender testMailSender = new TbMailSender(tbMailContextComponent, jsonConfig);
+ TbMailSender testMailSender = new TbMailSender(ctx, jsonConfig);
String mailFrom = jsonConfig.get("mailFrom").asText();
String subject = messages.getMessage("test.message.subject", null, Locale.US);
long timeout = jsonConfig.get("timeout").asLong(DEFAULT_TIMEOUT);
@@ -214,6 +238,10 @@ public class DefaultMailService implements MailService {
private void sendMail(TenantId tenantId, CustomerId customerId, TbEmail tbEmail, JavaMailSender javaMailSender, long timeout) throws ThingsboardException {
if (apiUsageStateService.getApiUsageState(tenantId).isEmailSendEnabled()) {
+ if (tenantId != null && !tenantId.isSysTenantId() && StringUtils.isNotEmpty(perTenantRateLimitConfig) &&
+ !rateLimitService.checkRateLimit(LimitedApi.EMAILS, (Object) tenantId, perTenantRateLimitConfig)) {
+ throw new RateLimitExceededException(LimitedApi.EMAILS);
+ }
try {
MimeMessage mailMsg = javaMailSender.createMimeMessage();
boolean multipart = (tbEmail.getImages() != null && !tbEmail.getImages().isEmpty());
@@ -415,8 +443,11 @@ public class DefaultMailService implements MailService {
}
private void sendMailWithTimeout(JavaMailSender mailSender, MimeMessage msg, long timeout) {
+ var submittedMail = Futures.withTimeout(
+ mailExecutorService.submit(() -> mailSender.send(msg)),
+ timeout, TimeUnit.MILLISECONDS, timeoutScheduler);
try {
- mailExecutorService.submit(() -> mailSender.send(msg)).get(timeout, TimeUnit.MILLISECONDS);
+ submittedMail.get(timeout, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
log.debug("Error during mail submission", e);
throw new RuntimeException("Timeout!");
diff --git a/application/src/main/java/org/thingsboard/server/service/mail/MailSenderInternalExecutorService.java b/application/src/main/java/org/thingsboard/server/service/mail/MailSenderInternalExecutorService.java
new file mode 100644
index 0000000000..0c8feff6c5
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/mail/MailSenderInternalExecutorService.java
@@ -0,0 +1,37 @@
+/**
+ * Copyright © 2016-2024 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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.mail;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.thingsboard.common.util.AbstractListeningExecutor;
+
+/**
+ * Executor have the sole purpose to send mails. It should be used only by Mail Service.
+ * For other purposes please use the MailExecutorService component
+ * */
+@Component
+public class MailSenderInternalExecutorService extends AbstractListeningExecutor {
+
+ @Value("${actors.rule.mail_thread_pool_size}")
+ private int mailExecutorThreadPoolSize;
+
+ @Override
+ protected int getThreadPollSize() {
+ return mailExecutorThreadPoolSize;
+ }
+
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java b/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java
index 4df152936f..97c20fdcda 100644
--- a/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java
+++ b/application/src/main/java/org/thingsboard/server/service/mail/TbMailContextComponent.java
@@ -20,7 +20,6 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.thingsboard.server.dao.settings.AdminSettingsService;
-import org.thingsboard.server.queue.util.TbCoreComponent;
@Component
@Data
diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java
index f796e2b440..795f59b79c 100644
--- a/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java
+++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/MobileAppNotificationChannel.java
@@ -105,9 +105,10 @@ public class MobileAppNotificationChannel implements NotificationChannel implements TbCoreConsumerService {
@Value("${queue.core.poll-interval}")
- private long pollDuration;
+ private long pollInterval;
@Value("${queue.core.pack-processing-timeout}")
private long packProcessingTimeout;
+ @Value("${queue.core.consumer-per-partition:true}")
+ private boolean consumerPerPartition;
@Value("${queue.core.stats.enabled:false}")
private boolean statsEnabled;
@@ -133,7 +137,6 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> mainConsumer;
private final DeviceStateService stateService;
private final TbApiUsageStateService statsService;
private final TbLocalSubscriptionService localSubscriptionService;
@@ -144,14 +147,14 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> usageStatsConsumer;
- private final TbQueueConsumer> firmwareStatesConsumer;
+ private final TbCoreQueueFactory queueFactory;
private final TbImageService imageService;
+ private final TbCoreConsumerStats stats;
+
+ private MainQueueConsumerManager, CoreQueueConfig> mainConsumer;
+ private QueueConsumerManager> usageStatsConsumer;
+ private QueueConsumerManager> firmwareStatesConsumer;
- protected volatile ExecutorService consumersExecutor;
- protected volatile ExecutorService usageStatsExecutor;
- private volatile ExecutorService firmwareStatesExecutor;
private volatile ListeningExecutorService deviceActivityEventsExecutor;
public DefaultTbCoreConsumerService(TbCoreQueueFactory tbCoreQueueFactory,
@@ -175,10 +178,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService, CoreQueueConfig>builder()
+ .queueKey(new QueueKey(ServiceType.TB_CORE))
+ .config(CoreQueueConfig.of(consumerPerPartition, (int) pollInterval))
+ .msgPackProcessor(this::processMsgs)
+ .consumerCreator(config -> queueFactory.createToCoreMsgConsumer())
+ .consumerExecutor(consumersExecutor)
+ .scheduler(scheduler)
+ .taskExecutor(mgmtExecutor)
+ .build();
+ this.usageStatsConsumer = QueueConsumerManager.>builder()
+ .name("TB Usage Stats")
+ .msgPackProcessor(this::processUsageStatsMsg)
+ .pollInterval(pollInterval)
+ .consumerCreator(queueFactory::createToUsageStatsServiceMsgConsumer)
+ .consumerExecutor(consumersExecutor)
+ .threadPrefix("usage-stats")
+ .build();
+ this.firmwareStatesConsumer = QueueConsumerManager.>builder()
+ .name("TB Ota Package States")
+ .msgPackProcessor(this::processFirmwareMsgs)
+ .pollInterval(pollInterval)
+ .consumerCreator(queueFactory::createToOtaPackageStateServiceMsgConsumer)
+ .consumerExecutor(consumersExecutor)
+ .threadPrefix("firmware")
+ .build();
}
@PreDestroy
public void destroy() {
super.destroy();
- if (consumersExecutor != null) {
- consumersExecutor.shutdownNow();
- }
- if (usageStatsExecutor != null) {
- usageStatsExecutor.shutdownNow();
- }
- if (firmwareStatesExecutor != null) {
- firmwareStatesExecutor.shutdownNow();
- }
if (deviceActivityEventsExecutor != null) {
deviceActivityEventsExecutor.shutdownNow();
}
}
- @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE)
- public void onApplicationEvent(ApplicationReadyEvent event) {
- super.onApplicationEvent(event);
- launchUsageStatsConsumer();
- launchOtaPackageUpdateNotificationConsumer();
+ @Override
+ protected void startConsumers() {
+ super.startConsumers();
+ firmwareStatesConsumer.subscribe();
+ firmwareStatesConsumer.launch();
+ usageStatsConsumer.launch();
}
@Override
protected void onTbApplicationEvent(PartitionChangeEvent event) {
log.info("Subscribing to partitions: {}", event.getPartitions());
- this.mainConsumer.subscribe(event.getPartitions());
- this.usageStatsConsumer.subscribe(
- event
- .getPartitions()
- .stream()
- .map(tpi -> tpi.newByTopic(usageStatsConsumer.getTopic()))
- .collect(Collectors.toSet()));
- this.firmwareStatesConsumer.subscribe();
- }
-
- @Override
- protected void launchMainConsumers() {
- consumersExecutor.submit(() -> {
- while (!stopped) {
+ mainConsumer.update(event.getPartitions());
+ usageStatsConsumer.subscribe(event.getPartitions()
+ .stream()
+ .map(tpi -> tpi.newByTopic(usageStatsConsumer.getConsumer().getTopic()))
+ .collect(Collectors.toSet()));
+ }
+
+ private void processMsgs(List> msgs, TbQueueConsumer> consumer, CoreQueueConfig config) throws Exception {
+ List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).collect(Collectors.toList());
+ ConcurrentMap> pendingMap = orderedMsgList.stream().collect(
+ Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg));
+ CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
+ TbPackProcessingContext> ctx = new TbPackProcessingContext<>(
+ processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
+ PendingMsgHolder pendingMsgHolder = new PendingMsgHolder();
+ Future> packSubmitFuture = consumersExecutor.submit(() -> {
+ orderedMsgList.forEach((element) -> {
+ UUID id = element.getUuid();
+ TbProtoQueueMsg msg = element.getMsg();
+ log.trace("[{}] Creating main callback for message: {}", id, msg.getValue());
+ TbCallback callback = new TbPackCallback<>(id, ctx);
try {
- List> msgs = mainConsumer.poll(pollDuration);
- if (msgs.isEmpty()) {
- continue;
- }
- List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).collect(Collectors.toList());
- ConcurrentMap> pendingMap = orderedMsgList.stream().collect(
- Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg));
- CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
- TbPackProcessingContext> ctx = new TbPackProcessingContext<>(
- processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
- PendingMsgHolder pendingMsgHolder = new PendingMsgHolder();
- Future> packSubmitFuture = consumersExecutor.submit(() -> {
- orderedMsgList.forEach((element) -> {
- UUID id = element.getUuid();
- TbProtoQueueMsg msg = element.getMsg();
- log.trace("[{}] Creating main callback for message: {}", id, msg.getValue());
- TbCallback callback = new TbPackCallback<>(id, ctx);
- try {
- ToCoreMsg toCoreMsg = msg.getValue();
- pendingMsgHolder.setToCoreMsg(toCoreMsg);
- if (toCoreMsg.hasToSubscriptionMgrMsg()) {
- log.trace("[{}] Forwarding message to subscription manager service {}", id, toCoreMsg.getToSubscriptionMgrMsg());
- forwardToSubMgrService(toCoreMsg.getToSubscriptionMgrMsg(), callback);
- } else if (toCoreMsg.hasToDeviceActorMsg()) {
- log.trace("[{}] Forwarding message to device actor {}", id, toCoreMsg.getToDeviceActorMsg());
- forwardToDeviceActor(toCoreMsg.getToDeviceActorMsg(), callback);
- } else if (toCoreMsg.hasDeviceStateServiceMsg()) {
- log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceStateServiceMsg());
- forwardToStateService(toCoreMsg.getDeviceStateServiceMsg(), callback);
- } else if (toCoreMsg.hasEdgeNotificationMsg()) {
- log.trace("[{}] Forwarding message to edge service {}", id, toCoreMsg.getEdgeNotificationMsg());
- forwardToEdgeNotificationService(toCoreMsg.getEdgeNotificationMsg(), callback);
- } else if (toCoreMsg.hasDeviceConnectMsg()) {
- log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceConnectMsg());
- forwardToStateService(toCoreMsg.getDeviceConnectMsg(), callback);
- } else if (toCoreMsg.hasDeviceActivityMsg()) {
- log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceActivityMsg());
- forwardToStateService(toCoreMsg.getDeviceActivityMsg(), callback);
- } else if (toCoreMsg.hasDeviceDisconnectMsg()) {
- log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceDisconnectMsg());
- forwardToStateService(toCoreMsg.getDeviceDisconnectMsg(), callback);
- } else if (toCoreMsg.hasDeviceInactivityMsg()) {
- log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceInactivityMsg());
- forwardToStateService(toCoreMsg.getDeviceInactivityMsg(), callback);
- } else if (toCoreMsg.hasToDeviceActorNotification()) {
- TbActorMsg actorMsg = ProtoUtils.fromProto(toCoreMsg.getToDeviceActorNotification());
- if (actorMsg != null) {
- if (actorMsg.getMsgType().equals(MsgType.DEVICE_RPC_REQUEST_TO_DEVICE_ACTOR_MSG)) {
- tbCoreDeviceRpcService.forwardRpcRequestToDeviceActor((ToDeviceRpcRequestActorMsg) actorMsg);
- } else {
- log.trace("[{}] Forwarding message to App Actor {}", id, actorMsg);
- actorContext.tell(actorMsg);
- }
- }
- callback.onSuccess();
- } else if (toCoreMsg.hasNotificationSchedulerServiceMsg()) {
- TransportProtos.NotificationSchedulerServiceMsg notificationSchedulerServiceMsg = toCoreMsg.getNotificationSchedulerServiceMsg();
- log.trace("[{}] Forwarding message to notification scheduler service {}", id, toCoreMsg.getNotificationSchedulerServiceMsg());
- forwardToNotificationSchedulerService(notificationSchedulerServiceMsg, callback);
- } else if (toCoreMsg.hasErrorEventMsg()) {
- forwardToEventService(toCoreMsg.getErrorEventMsg(), callback);
- } else if (toCoreMsg.hasLifecycleEventMsg()) {
- forwardToEventService(toCoreMsg.getLifecycleEventMsg(), callback);
- }
- } catch (Throwable e) {
- log.warn("[{}] Failed to process message: {}", id, msg, e);
- callback.onFailure(e);
+ ToCoreMsg toCoreMsg = msg.getValue();
+ pendingMsgHolder.setToCoreMsg(toCoreMsg);
+ if (toCoreMsg.hasToSubscriptionMgrMsg()) {
+ log.trace("[{}] Forwarding message to subscription manager service {}", id, toCoreMsg.getToSubscriptionMgrMsg());
+ forwardToSubMgrService(toCoreMsg.getToSubscriptionMgrMsg(), callback);
+ } else if (toCoreMsg.hasToDeviceActorMsg()) {
+ log.trace("[{}] Forwarding message to device actor {}", id, toCoreMsg.getToDeviceActorMsg());
+ forwardToDeviceActor(toCoreMsg.getToDeviceActorMsg(), callback);
+ } else if (toCoreMsg.hasDeviceStateServiceMsg()) {
+ log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceStateServiceMsg());
+ forwardToStateService(toCoreMsg.getDeviceStateServiceMsg(), callback);
+ } else if (toCoreMsg.hasEdgeNotificationMsg()) {
+ log.trace("[{}] Forwarding message to edge service {}", id, toCoreMsg.getEdgeNotificationMsg());
+ forwardToEdgeNotificationService(toCoreMsg.getEdgeNotificationMsg(), callback);
+ } else if (toCoreMsg.hasDeviceConnectMsg()) {
+ log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceConnectMsg());
+ forwardToStateService(toCoreMsg.getDeviceConnectMsg(), callback);
+ } else if (toCoreMsg.hasDeviceActivityMsg()) {
+ log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceActivityMsg());
+ forwardToStateService(toCoreMsg.getDeviceActivityMsg(), callback);
+ } else if (toCoreMsg.hasDeviceDisconnectMsg()) {
+ log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceDisconnectMsg());
+ forwardToStateService(toCoreMsg.getDeviceDisconnectMsg(), callback);
+ } else if (toCoreMsg.hasDeviceInactivityMsg()) {
+ log.trace("[{}] Forwarding message to device state service {}", id, toCoreMsg.getDeviceInactivityMsg());
+ forwardToStateService(toCoreMsg.getDeviceInactivityMsg(), callback);
+ } else if (toCoreMsg.hasToDeviceActorNotification()) {
+ TbActorMsg actorMsg = ProtoUtils.fromProto(toCoreMsg.getToDeviceActorNotification());
+ if (actorMsg != null) {
+ if (actorMsg.getMsgType().equals(MsgType.DEVICE_RPC_REQUEST_TO_DEVICE_ACTOR_MSG)) {
+ tbCoreDeviceRpcService.forwardRpcRequestToDeviceActor((ToDeviceRpcRequestActorMsg) actorMsg);
+ } else {
+ log.trace("[{}] Forwarding message to App Actor {}", id, actorMsg);
+ actorContext.tell(actorMsg);
}
- });
- });
- if (!processingTimeoutLatch.await(packProcessingTimeout, TimeUnit.MILLISECONDS)) {
- if (!packSubmitFuture.isDone()) {
- packSubmitFuture.cancel(true);
- ToCoreMsg lastSubmitMsg = pendingMsgHolder.getToCoreMsg();
- log.info("Timeout to process message: {}", lastSubmitMsg);
- }
- ctx.getAckMap().forEach((id, msg) -> log.debug("[{}] Timeout to process message: {}", id, msg.getValue()));
- ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process message: {}", id, msg.getValue()));
- }
- mainConsumer.commit();
- } catch (Exception e) {
- if (!stopped) {
- 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);
}
+ callback.onSuccess();
+ } else if (toCoreMsg.hasNotificationSchedulerServiceMsg()) {
+ TransportProtos.NotificationSchedulerServiceMsg notificationSchedulerServiceMsg = toCoreMsg.getNotificationSchedulerServiceMsg();
+ log.trace("[{}] Forwarding message to notification scheduler service {}", id, toCoreMsg.getNotificationSchedulerServiceMsg());
+ forwardToNotificationSchedulerService(notificationSchedulerServiceMsg, callback);
+ } else if (toCoreMsg.hasErrorEventMsg()) {
+ forwardToEventService(toCoreMsg.getErrorEventMsg(), callback);
+ } else if (toCoreMsg.hasLifecycleEventMsg()) {
+ forwardToEventService(toCoreMsg.getLifecycleEventMsg(), callback);
}
+ } catch (Throwable e) {
+ log.warn("[{}] Failed to process message: {}", id, msg, e);
+ callback.onFailure(e);
}
- }
- log.info("TB Core Consumer stopped.");
+ });
});
+ if (!processingTimeoutLatch.await(packProcessingTimeout, TimeUnit.MILLISECONDS)) {
+ if (!packSubmitFuture.isDone()) {
+ packSubmitFuture.cancel(true);
+ ToCoreMsg lastSubmitMsg = pendingMsgHolder.getToCoreMsg();
+ log.info("Timeout to process message: {}", lastSubmitMsg);
+ }
+ if (log.isDebugEnabled()) {
+ ctx.getAckMap().forEach((id, msg) -> log.debug("[{}] Timeout to process message: {}", id, msg.getValue()));
+ }
+ ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process message: {}", id, msg.getValue()));
+ }
+ consumer.commit();
}
private static class PendingMsgHolder {
@@ -352,7 +347,7 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> createNotificationsConsumer() {
+ return queueFactory.createToCoreNotificationsMsgConsumer();
+ }
+
@Override
protected void handleNotification(UUID id, TbProtoQueueMsg msg, TbCallback callback) {
ToCoreNotificationMsg toCoreNotification = msg.getValue();
@@ -408,92 +413,55 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService {
- while (!stopped) {
- try {
- List> msgs = usageStatsConsumer.poll(getNotificationPollDuration());
- if (msgs.isEmpty()) {
- continue;
- }
- ConcurrentMap> pendingMap = msgs.stream().collect(
- Collectors.toConcurrentMap(s -> UUID.randomUUID(), Function.identity()));
- CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
- TbPackProcessingContext> ctx = new TbPackProcessingContext<>(
- processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
- pendingMap.forEach((id, msg) -> {
- log.trace("[{}] Creating usage stats callback for message: {}", id, msg.getValue());
- TbCallback callback = new TbPackCallback<>(id, ctx);
- try {
- handleUsageStats(msg, callback);
- } catch (Throwable e) {
- log.warn("[{}] Failed to process usage stats: {}", id, msg, e);
- callback.onFailure(e);
- }
- });
- if (!processingTimeoutLatch.await(getNotificationPackProcessingTimeout(), TimeUnit.MILLISECONDS)) {
- ctx.getAckMap().forEach((id, msg) -> log.warn("[{}] Timeout to process usage stats: {}", id, msg.getValue()));
- ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process usage stats: {}", id, msg.getValue()));
- }
- usageStatsConsumer.commit();
- } catch (Exception e) {
- if (!stopped) {
- log.warn("Failed to obtain usage stats from queue.", e);
- try {
- Thread.sleep(getNotificationPollDuration());
- } catch (InterruptedException e2) {
- log.trace("Failed to wait until the server has capacity to handle new usage stats", e2);
- }
- }
- }
+ private void processUsageStatsMsg(List> msgs, TbQueueConsumer> consumer) throws Exception {
+ ConcurrentMap> pendingMap = msgs.stream().collect(
+ Collectors.toConcurrentMap(s -> UUID.randomUUID(), Function.identity()));
+ CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
+ TbPackProcessingContext> ctx = new TbPackProcessingContext<>(
+ processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
+ pendingMap.forEach((id, msg) -> {
+ log.trace("[{}] Creating usage stats callback for message: {}", id, msg.getValue());
+ TbCallback callback = new TbPackCallback<>(id, ctx);
+ try {
+ handleUsageStats(msg, callback);
+ } catch (Throwable e) {
+ log.warn("[{}] Failed to process usage stats: {}", id, msg, e);
+ callback.onFailure(e);
}
- log.info("TB Usage Stats Consumer stopped.");
});
+ if (!processingTimeoutLatch.await(getNotificationPackProcessingTimeout(), TimeUnit.MILLISECONDS)) {
+ ctx.getAckMap().forEach((id, msg) -> log.warn("[{}] Timeout to process usage stats: {}", id, msg.getValue()));
+ ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process usage stats: {}", id, msg.getValue()));
+ }
+ consumer.commit();
+
}
- private void launchOtaPackageUpdateNotificationConsumer() {
+ private void processFirmwareMsgs(List> msgs, TbQueueConsumer> consumer) {
long maxProcessingTimeoutPerRecord = firmwarePackInterval / firmwarePackSize;
- firmwareStatesExecutor.submit(() -> {
- while (!stopped) {
- try {
- List> msgs = firmwareStatesConsumer.poll(getNotificationPollDuration());
- if (msgs.isEmpty()) {
- continue;
- }
- long timeToSleep = maxProcessingTimeoutPerRecord;
- for (TbProtoQueueMsg msg : msgs) {
- try {
- long startTime = System.currentTimeMillis();
- boolean isSuccessUpdate = handleOtaPackageUpdates(msg);
- long endTime = System.currentTimeMillis();
- long spentTime = endTime - startTime;
- timeToSleep = timeToSleep - spentTime;
- if (isSuccessUpdate) {
- if (timeToSleep > 0) {
- log.debug("Spent time per record is: [{}]!", spentTime);
- Thread.sleep(timeToSleep);
- timeToSleep = 0;
- }
- timeToSleep += maxProcessingTimeoutPerRecord;
- }
- } catch (Throwable e) {
- log.warn("Failed to process firmware update msg: {}", msg, e);
- }
- }
- firmwareStatesConsumer.commit();
- } catch (Exception e) {
- if (!stopped) {
- log.warn("Failed to obtain usage stats from queue.", e);
- try {
- Thread.sleep(getNotificationPollDuration());
- } catch (InterruptedException e2) {
- log.trace("Failed to wait until the server has capacity to handle new firmware updates", e2);
- }
+ long timeToSleep = maxProcessingTimeoutPerRecord;
+ for (TbProtoQueueMsg msg : msgs) {
+ try {
+ long startTime = System.currentTimeMillis();
+ boolean isSuccessUpdate = handleOtaPackageUpdates(msg);
+ long endTime = System.currentTimeMillis();
+ long spentTime = endTime - startTime;
+ timeToSleep = timeToSleep - spentTime;
+ if (isSuccessUpdate) {
+ if (timeToSleep > 0) {
+ log.debug("Spent time per record is: [{}]!", spentTime);
+ Thread.sleep(timeToSleep);
+ timeToSleep = 0;
}
+ timeToSleep += maxProcessingTimeoutPerRecord;
}
+ } catch (InterruptedException e) {
+ return;
+ } catch (Throwable e) {
+ log.warn("Failed to process firmware update msg: {}", msg, e);
}
- log.info("TB Ota Package States Consumer stopped.");
- });
+ }
+ consumer.commit();
}
private void handleUsageStats(TbProtoQueueMsg msg, TbCallback callback) {
@@ -781,15 +749,17 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService consumers = new ConcurrentHashMap<>();
public DefaultTbRuleEngineConsumerService(TbRuleEngineConsumerContext ctx,
- TbRuleEngineQueueFactory tbRuleEngineQueueFactory,
ActorSystemContext actorContext,
TbRuleEngineDeviceRpcService tbDeviceRpcService,
QueueService queueService,
@@ -87,8 +84,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractConsumerService<
PartitionService partitionService,
ApplicationEventPublisher eventPublisher,
JwtSettingsService jwtSettingsService) {
- super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, apiUsageStateService, partitionService,
- eventPublisher, tbRuleEngineQueueFactory.createToRuleEngineNotificationsMsgConsumer(), jwtSettingsService);
+ super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService);
this.ctx = ctx;
this.tbDeviceRpcService = tbDeviceRpcService;
this.queueService = queueService;
@@ -96,7 +92,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractConsumerService<
@PostConstruct
public void init() {
- super.init("tb-rule-engine-notifications-consumer");
+ super.init("tb-rule-engine");
List queues = queueService.findAllQueues();
for (Queue configuration : queues) {
if (partitionService.isManagedByCurrentService(configuration.getTenantId())) {
@@ -136,20 +132,11 @@ public class DefaultTbRuleEngineConsumerService extends AbstractConsumerService<
});
}
- @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE)
- public void onApplicationEvent(ApplicationReadyEvent event) {
- super.onApplicationEvent(event);
- ctx.setReady(true);
- }
-
- @Override
- protected void launchMainConsumers() {}
-
@Override
protected void stopConsumers() {
+ super.stopConsumers();
consumers.values().forEach(TbRuleEngineQueueConsumerManager::stop);
consumers.values().forEach(TbRuleEngineQueueConsumerManager::awaitStop);
- ctx.stop();
}
@Override
@@ -167,6 +154,16 @@ public class DefaultTbRuleEngineConsumerService extends AbstractConsumerService<
return ctx.getPackProcessingTimeout();
}
+ @Override
+ protected int getMgmtThreadPoolSize() {
+ return ctx.getMgmtThreadPoolSize();
+ }
+
+ @Override
+ protected TbQueueConsumer> createNotificationsConsumer() {
+ return ctx.getQueueFactory().createToRuleEngineNotificationsMsgConsumer();
+ }
+
@Override
protected void handleNotification(UUID id, TbProtoQueueMsg msg, TbCallback callback) throws Exception {
ToRuleEngineNotificationMsg nfMsg = msg.getValue();
@@ -243,7 +240,13 @@ public class DefaultTbRuleEngineConsumerService extends AbstractConsumerService<
}
private TbRuleEngineQueueConsumerManager createConsumer(QueueKey queueKey, Queue queue) {
- var consumer = new TbRuleEngineQueueConsumerManager(ctx, queueKey);
+ var consumer = TbRuleEngineQueueConsumerManager.create()
+ .ctx(ctx)
+ .queueKey(queueKey)
+ .consumerExecutor(consumersExecutor)
+ .scheduler(scheduler)
+ .taskExecutor(mgmtExecutor)
+ .build();
consumers.put(queueKey, consumer);
consumer.init(queue);
return consumer;
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/consumer/MainQueueConsumerManager.java b/application/src/main/java/org/thingsboard/server/service/queue/consumer/MainQueueConsumerManager.java
new file mode 100644
index 0000000000..6792422b57
--- /dev/null
+++ b/application/src/main/java/org/thingsboard/server/service/queue/consumer/MainQueueConsumerManager.java
@@ -0,0 +1,322 @@
+/**
+ * Copyright © 2016-2024 The Thingsboard Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT 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.consumer;
+
+import lombok.Builder;
+import lombok.Getter;
+import lombok.extern.slf4j.Slf4j;
+import org.thingsboard.common.util.ThingsBoardThreadFactory;
+import org.thingsboard.server.common.data.queue.QueueConfig;
+import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
+import org.thingsboard.server.queue.TbQueueConsumer;
+import org.thingsboard.server.queue.TbQueueMsg;
+import org.thingsboard.server.queue.discovery.QueueKey;
+import org.thingsboard.server.service.queue.ruleengine.QueueEvent;
+import org.thingsboard.server.service.queue.ruleengine.TbQueueConsumerManagerTask;
+import org.thingsboard.server.service.queue.ruleengine.TbQueueConsumerTask;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Slf4j
+public class MainQueueConsumerManager {
+
+ protected final QueueKey queueKey;
+ @Getter
+ protected C config;
+ protected final MsgPackProcessor msgPackProcessor;
+ protected final Function> consumerCreator;
+ protected final ExecutorService consumerExecutor;
+ protected final ScheduledExecutorService scheduler;
+ protected final ExecutorService taskExecutor;
+
+ private final java.util.Queue tasks = new ConcurrentLinkedQueue<>();
+ private final ReentrantLock lock = new ReentrantLock();
+
+ @Getter
+ private volatile Set partitions;
+ protected volatile ConsumerWrapper consumerWrapper;
+ protected volatile boolean stopped;
+
+ @Builder
+ public MainQueueConsumerManager(QueueKey queueKey, C config,
+ MsgPackProcessor msgPackProcessor,
+ Function> consumerCreator,
+ ExecutorService consumerExecutor,
+ ScheduledExecutorService scheduler,
+ ExecutorService taskExecutor) {
+ this.queueKey = queueKey;
+ this.config = config;
+ this.msgPackProcessor = msgPackProcessor;
+ this.consumerCreator = consumerCreator;
+ this.consumerExecutor = consumerExecutor;
+ this.scheduler = scheduler;
+ this.taskExecutor = taskExecutor;
+ if (config != null) {
+ init(config);
+ }
+ }
+
+ public void init(C config) {
+ this.config = config;
+ if (config.isConsumerPerPartition()) {
+ this.consumerWrapper = new ConsumerPerPartitionWrapper();
+ } else {
+ this.consumerWrapper = new SingleConsumerWrapper();
+ }
+ log.debug("[{}] Initialized consumer for queue: {}", queueKey, config);
+ }
+
+ public void update(C config) {
+ addTask(TbQueueConsumerManagerTask.configUpdate(config));
+ }
+
+ public void update(Set partitions) {
+ addTask(TbQueueConsumerManagerTask.partitionChange(partitions));
+ }
+
+ protected void addTask(TbQueueConsumerManagerTask todo) {
+ if (stopped) {
+ return;
+ }
+ tasks.add(todo);
+ log.trace("[{}] Added task: {}", queueKey, todo);
+ tryProcessTasks();
+ }
+
+ private void tryProcessTasks() {
+ taskExecutor.submit(() -> {
+ if (lock.tryLock()) {
+ try {
+ C newConfig = null;
+ Set newPartitions = null;
+ while (!stopped) {
+ TbQueueConsumerManagerTask task = tasks.poll();
+ if (task == null) {
+ break;
+ }
+ log.trace("[{}] Processing task: {}", queueKey, task);
+
+ if (task.getEvent() == QueueEvent.PARTITION_CHANGE) {
+ newPartitions = task.getPartitions();
+ } else if (task.getEvent() == QueueEvent.CONFIG_UPDATE) {
+ newConfig = (C) task.getConfig();
+ } else {
+ processTask(task);
+ }
+ }
+ if (stopped) {
+ return;
+ }
+ if (newConfig != null) {
+ doUpdate(newConfig);
+ }
+ if (newPartitions != null) {
+ doUpdate(newPartitions);
+ }
+ } catch (Exception e) {
+ log.error("[{}] Failed to process tasks", queueKey, e);
+ } finally {
+ lock.unlock();
+ }
+ } else {
+ log.trace("[{}] Failed to acquire lock", queueKey);
+ scheduler.schedule(this::tryProcessTasks, 1, TimeUnit.SECONDS);
+ }
+ });
+ }
+
+ protected void processTask(TbQueueConsumerManagerTask task) {
+ }
+
+ private void doUpdate(C newConfig) {
+ log.info("[{}] Processing queue update: {}", queueKey, newConfig);
+ var oldConfig = this.config;
+ this.config = newConfig;
+ if (log.isTraceEnabled()) {
+ log.trace("[{}] Old queue configuration: {}", queueKey, oldConfig);
+ log.trace("[{}] New queue configuration: {}", queueKey, newConfig);
+ }
+
+ if (oldConfig == null) {
+ init(config);
+ } else if (newConfig.isConsumerPerPartition() != oldConfig.isConsumerPerPartition()) {
+ consumerWrapper.getConsumers().forEach(TbQueueConsumerTask::initiateStop);
+ consumerWrapper.getConsumers().forEach(TbQueueConsumerTask::awaitCompletion);
+
+ init(config);
+ if (partitions != null) {
+ doUpdate(partitions); // even if partitions number was changed, there can be no partition change event
+ }
+ } else {
+ log.trace("[{}] Silently applied new config, because consumer-per-partition not changed", queueKey);
+ // do nothing, because partitions change (if they changed) will be handled on PartitionChangeEvent,
+ // and changes to other config values will be picked up by consumer on the fly,
+ // and queue topic and name are immutable
+ }
+ }
+
+ private void doUpdate(Set partitions) {
+ this.partitions = partitions;
+ consumerWrapper.updatePartitions(partitions);
+ }
+
+ private void launchConsumer(TbQueueConsumerTask consumerTask) {
+ log.info("[{}] Launching consumer", consumerTask.getKey());
+ Future> consumerLoop = consumerExecutor.submit(() -> {
+ ThingsBoardThreadFactory.updateCurrentThreadName(consumerTask.getKey().toString());
+ try {
+ consumerLoop(consumerTask.getConsumer());
+ } catch (Throwable e) {
+ log.error("Failure in consumer loop", e);
+ }
+ log.info("[{}] Consumer stopped", consumerTask.getKey());
+ });
+ consumerTask.setTask(consumerLoop);
+ }
+
+ private void consumerLoop(TbQueueConsumer consumer) {
+ while (!stopped && !consumer.isStopped()) {
+ try {
+ List msgs = consumer.poll(config.getPollInterval());
+ if (msgs.isEmpty()) {
+ continue;
+ }
+ processMsgs(msgs, consumer, config);
+ } catch (Exception e) {
+ if (!consumer.isStopped()) {
+ log.warn("Failed to process messages from queue", e);
+ try {
+ Thread.sleep(config.getPollInterval());
+ } catch (InterruptedException e2) {
+ log.trace("Failed to wait until the server has capacity to handle new requests", e2);
+ }
+ }
+ }
+ }
+ if (consumer.isStopped()) {
+ consumer.unsubscribe();
+ }
+ }
+
+ protected void processMsgs(List msgs, TbQueueConsumer consumer, C config) throws Exception {
+ msgPackProcessor.process(msgs, consumer, config);
+ }
+
+ public void stop() {
+ log.debug("[{}] Stopping consumers", queueKey);
+ consumerWrapper.getConsumers().forEach(TbQueueConsumerTask::initiateStop);
+ stopped = true;
+ }
+
+ public void awaitStop() {
+ log.debug("[{}] Waiting for consumers to stop", queueKey);
+ consumerWrapper.getConsumers().forEach(TbQueueConsumerTask::awaitCompletion);
+ log.debug("[{}] Unsubscribed and stopped consumers", queueKey);
+ }
+
+ private static String partitionsToString(Collection partitions) {
+ return partitions.stream().map(TopicPartitionInfo::getFullTopicName).collect(Collectors.joining(", ", "[", "]"));
+ }
+
+ public interface MsgPackProcessor {
+ void process(List msgs, TbQueueConsumer consumer, C config) throws Exception;
+ }
+
+ public interface ConsumerWrapper {
+
+ void updatePartitions(Set partitions);
+
+ Collection> getConsumers();
+
+ }
+
+ class ConsumerPerPartitionWrapper implements ConsumerWrapper {
+ private final Map> consumers = new HashMap<>();
+
+ @Override
+ public void updatePartitions(Set partitions) {
+ Set addedPartitions = new HashSet<>(partitions);
+ addedPartitions.removeAll(consumers.keySet());
+
+ Set removedPartitions = new HashSet<>(consumers.keySet());
+ removedPartitions.removeAll(partitions);
+ log.info("[{}] Added partitions: {}, removed partitions: {}", queueKey, partitionsToString(addedPartitions), partitionsToString(removedPartitions));
+
+ removedPartitions.forEach((tpi) -> consumers.get(tpi).initiateStop());
+ removedPartitions.forEach((tpi) -> consumers.remove(tpi).awaitCompletion());
+
+ addedPartitions.forEach((tpi) -> {
+ String key = queueKey + "-" + tpi.getPartition().orElse(-1);
+ TbQueueConsumerTask consumer = new TbQueueConsumerTask<>(key, consumerCreator.apply(config));
+ consumers.put(tpi, consumer);
+ consumer.subscribe(Set.of(tpi));
+ launchConsumer(consumer);
+ });
+ }
+
+ @Override
+ public Collection> getConsumers() {
+ return consumers.values();
+ }
+ }
+
+ class SingleConsumerWrapper implements ConsumerWrapper {
+ private TbQueueConsumerTask consumer;
+
+ @Override
+ public void updatePartitions(Set partitions) {
+ log.info("[{}] New partitions: {}", queueKey, partitionsToString(partitions));
+ if (partitions.isEmpty()) {
+ if (consumer != null && consumer.isRunning()) {
+ consumer.initiateStop();
+ consumer.awaitCompletion();
+ }
+ consumer = null;
+ return;
+ }
+
+ if (consumer == null) {
+ consumer = new TbQueueConsumerTask<>(queueKey, consumerCreator.apply(config));
+ }
+ consumer.subscribe(partitions);
+ if (!consumer.isRunning()) {
+ launchConsumer(consumer);
+ }
+ }
+
+ @Override
+ public Collection> getConsumers() {
+ if (consumer == null) {
+ return Collections.emptyList();
+ }
+ return List.of(consumer);
+ }
+ }
+}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
index e5233c812e..a264f912cb 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java
@@ -15,10 +15,11 @@
*/
package org.thingsboard.server.service.queue.processing;
+import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationEventPublisher;
+import org.thingsboard.common.util.ThingsBoardExecutors;
import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.data.EntityType;
@@ -36,6 +37,7 @@ import org.thingsboard.server.common.msg.queue.TbCallback;
import org.thingsboard.server.dao.tenant.TbTenantProfileCache;
import org.thingsboard.server.queue.TbQueueConsumer;
import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import org.thingsboard.server.queue.common.consumer.QueueConsumerManager;
import org.thingsboard.server.queue.discovery.PartitionService;
import org.thingsboard.server.queue.discovery.TbApplicationEventListener;
import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent;
@@ -47,7 +49,6 @@ import org.thingsboard.server.service.queue.TbPackCallback;
import org.thingsboard.server.service.queue.TbPackProcessingContext;
import org.thingsboard.server.service.security.auth.jwt.settings.JwtSettingsService;
-import jakarta.annotation.PreDestroy;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@@ -55,6 +56,7 @@ import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@@ -62,9 +64,6 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public abstract class AbstractConsumerService extends TbApplicationEventListener {
- protected volatile ExecutorService notificationsConsumerExecutor;
- protected volatile boolean stopped = false;
- protected volatile boolean isReady = false;
protected final ActorSystemContext actorContext;
protected final TbTenantProfileCache tenantProfileCache;
protected final TbDeviceProfileCache deviceProfileCache;
@@ -72,21 +71,37 @@ public abstract class AbstractConsumerService> nfConsumer;
protected final JwtSettingsService jwtSettingsService;
- public void init(String nfConsumerThreadName) {
- this.notificationsConsumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName(nfConsumerThreadName));
+ protected QueueConsumerManager> nfConsumer;
+
+ protected ExecutorService consumersExecutor;
+ protected ExecutorService mgmtExecutor;
+ protected ScheduledExecutorService scheduler;
+
+ public void init(String prefix) {
+ this.consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName(prefix + "-consumer"));
+ this.mgmtExecutor = ThingsBoardExecutors.newWorkStealingPool(getMgmtThreadPoolSize(), prefix + "-mgmt");
+ this.scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName(prefix + "-consumer-scheduler"));
+
+ this.nfConsumer = QueueConsumerManager.>builder()
+ .name(getServiceType().getLabel() + " Notifications")
+ .msgPackProcessor(this::processNotifications)
+ .pollInterval(getNotificationPollDuration())
+ .consumerCreator(this::createNotificationsConsumer)
+ .consumerExecutor(consumersExecutor)
+ .threadPrefix("notifications")
+ .build();
}
@AfterStartUp(order = AfterStartUp.REGULAR_SERVICE)
- public void onApplicationEvent(ApplicationReadyEvent event) {
- log.info("Subscribing to notifications: {}", nfConsumer.getTopic());
- this.nfConsumer.subscribe();
- this.isReady = true;
- launchNotificationsConsumer();
- launchMainConsumers();
+ public void afterStartUp() {
+ startConsumers();
+ }
+
+ protected void startConsumers() {
+ nfConsumer.subscribe();
+ nfConsumer.launch();
}
@Override
@@ -96,58 +111,42 @@ public abstract class AbstractConsumerService {
- while (!stopped) {
- try {
- List> msgs = nfConsumer.poll(getNotificationPollDuration());
- if (msgs.isEmpty()) {
- continue;
- }
- List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).collect(Collectors.toList());
- ConcurrentMap> pendingMap = orderedMsgList.stream().collect(
- Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg));
- CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
- TbPackProcessingContext> ctx = new TbPackProcessingContext<>(
- processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
- orderedMsgList.forEach(element -> {
- UUID id = element.getUuid();
- TbProtoQueueMsg msg = element.getMsg();
- log.trace("[{}] Creating notification callback for message: {}", id, msg.getValue());
- TbCallback callback = new TbPackCallback<>(id, ctx);
- try {
- handleNotification(id, msg, callback);
- } catch (Throwable e) {
- log.warn("[{}] Failed to process notification: {}", id, msg, e);
- callback.onFailure(e);
- }
- });
- if (!processingTimeoutLatch.await(getNotificationPackProcessingTimeout(), TimeUnit.MILLISECONDS)) {
- ctx.getAckMap().forEach((id, msg) -> log.warn("[{}] Timeout to process notification: {}", id, msg.getValue()));
- ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process notification: {}", id, msg.getValue()));
- }
- nfConsumer.commit();
- } catch (Exception e) {
- if (!stopped) {
- log.warn("Failed to obtain notifications from queue.", e);
- try {
- Thread.sleep(getNotificationPollDuration());
- } catch (InterruptedException e2) {
- log.trace("Failed to wait until the server has capacity to handle new notifications", e2);
- }
- }
- }
+ protected abstract int getMgmtThreadPoolSize();
+
+ protected abstract TbQueueConsumer> createNotificationsConsumer();
+
+ protected void processNotifications(List> msgs, TbQueueConsumer> consumer) throws Exception {
+ List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).collect(Collectors.toList());
+ ConcurrentMap> pendingMap = orderedMsgList.stream().collect(
+ Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg));
+ CountDownLatch processingTimeoutLatch = new CountDownLatch(1);
+ TbPackProcessingContext> ctx = new TbPackProcessingContext<>(
+ processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>());
+ orderedMsgList.forEach(element -> {
+ UUID id = element.getUuid();
+ TbProtoQueueMsg msg = element.getMsg();
+ log.trace("[{}] Creating notification callback for message: {}", id, msg.getValue());
+ TbCallback callback = new TbPackCallback<>(id, ctx);
+ try {
+ handleNotification(id, msg, callback);
+ } catch (Throwable e) {
+ log.warn("[{}] Failed to process notification: {}", id, msg, e);
+ callback.onFailure(e);
}
- log.info("TB Notifications Consumer stopped.");
});
+ if (!processingTimeoutLatch.await(getNotificationPackProcessingTimeout(), TimeUnit.MILLISECONDS)) {
+ ctx.getAckMap().forEach((id, msg) -> log.warn("[{}] Timeout to process notification: {}", id, msg.getValue()));
+ ctx.getFailedMap().forEach((id, msg) -> log.warn("[{}] Failed to process notification: {}", id, msg.getValue()));
+ }
+ consumer.commit();
}
protected final void handleComponentLifecycleMsg(UUID id, ComponentLifecycleMsg componentLifecycleMsg) {
@@ -201,13 +200,15 @@ public abstract class AbstractConsumerService partitions;
private boolean drainQueue;
@@ -37,8 +37,8 @@ public class TbQueueConsumerManagerTask {
return new TbQueueConsumerManagerTask(QueueEvent.DELETE, null, null, drainQueue);
}
- public static TbQueueConsumerManagerTask configUpdate(Queue queue) {
- return new TbQueueConsumerManagerTask(QueueEvent.CONFIG_UPDATE, queue, null, false);
+ public static TbQueueConsumerManagerTask configUpdate(QueueConfig config) {
+ return new TbQueueConsumerManagerTask(QueueEvent.CONFIG_UPDATE, config, null, false);
}
public static TbQueueConsumerManagerTask partitionChange(Set partitions) {
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbQueueConsumerTask.java b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbQueueConsumerTask.java
index 0e25efd014..84539d4d7c 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbQueueConsumerTask.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbQueueConsumerTask.java
@@ -20,9 +20,8 @@ import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.thingsboard.server.common.msg.queue.TopicPartitionInfo;
-import org.thingsboard.server.gen.transport.TransportProtos;
import org.thingsboard.server.queue.TbQueueConsumer;
-import org.thingsboard.server.queue.common.TbProtoQueueMsg;
+import org.thingsboard.server.queue.TbQueueMsg;
import java.util.Set;
import java.util.concurrent.Future;
@@ -30,12 +29,12 @@ import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor
@Slf4j
-public class TbQueueConsumerTask {
+public class TbQueueConsumerTask {
@Getter
private final Object key;
@Getter
- private final TbQueueConsumer> consumer;
+ private final TbQueueConsumer consumer;
@Setter
private Future> task;
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineConsumerContext.java b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineConsumerContext.java
index cf876c6947..08d2931246 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineConsumerContext.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineConsumerContext.java
@@ -19,8 +19,6 @@ import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
-import org.thingsboard.common.util.ThingsBoardExecutors;
-import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.actors.ActorSystemContext;
import org.thingsboard.server.common.stats.StatsFactory;
import org.thingsboard.server.queue.TbQueueAdmin;
@@ -33,11 +31,6 @@ import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStr
import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory;
import org.thingsboard.server.service.stats.RuleEngineStatisticsService;
-import javax.annotation.PostConstruct;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-import java.util.concurrent.ScheduledExecutorService;
-
@Component
@TbRuleEngineComponent
@Slf4j
@@ -68,22 +61,4 @@ public class TbRuleEngineConsumerContext {
private final TbQueueProducerProvider producerProvider;
private final TbQueueAdmin queueAdmin;
- private ExecutorService consumersExecutor;
- private ExecutorService mgmtExecutor;
- private ScheduledExecutorService scheduler;
-
- private volatile boolean isReady = false;
-
- @PostConstruct
- void init() {
- this.consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("tb-rule-engine-consumer"));
- this.mgmtExecutor = ThingsBoardExecutors.newWorkStealingPool(mgmtThreadPoolSize, "tb-rule-engine-mgmt");
- this.scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("tb-rule-engine-consumer-scheduler"));
- }
-
- public void stop() {
- scheduler.shutdownNow();
- consumersExecutor.shutdownNow();
- mgmtExecutor.shutdownNow();
- }
}
diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java
index 2da3dbc6dc..c2823d3c00 100644
--- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java
+++ b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java
@@ -16,9 +16,8 @@
package org.thingsboard.server.service.queue.ruleengine;
import com.google.protobuf.ProtocolStringList;
-import lombok.Getter;
+import lombok.Builder;
import lombok.extern.slf4j.Slf4j;
-import org.thingsboard.common.util.ThingsBoardThreadFactory;
import org.thingsboard.server.common.data.id.EntityId;
import org.thingsboard.server.common.data.id.EntityIdFactory;
import org.thingsboard.server.common.data.id.TenantId;
@@ -38,173 +37,54 @@ import org.thingsboard.server.queue.discovery.QueueKey;
import org.thingsboard.server.service.queue.TbMsgPackCallback;
import org.thingsboard.server.service.queue.TbMsgPackProcessingContext;
import org.thingsboard.server.service.queue.TbRuleEngineConsumerStats;
+import org.thingsboard.server.service.queue.consumer.MainQueueConsumerManager;
import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingDecision;
import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult;
import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategy;
import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy;
-import java.util.Collection;
import java.util.Collections;
-import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
-import java.util.concurrent.ConcurrentLinkedQueue;
-import java.util.concurrent.Future;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
-import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
@Slf4j
-public class TbRuleEngineQueueConsumerManager {
+public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager, Queue> {
public static final String SUCCESSFUL_STATUS = "successful";
public static final String FAILED_STATUS = "failed";
private final TbRuleEngineConsumerContext ctx;
- private final QueueKey queueKey;
private final TbRuleEngineConsumerStats stats;
- private final ReentrantLock lock = new ReentrantLock(); //NonfairSync
- @Getter
- private volatile Queue queue;
- @Getter
- private volatile Set partitions;
- private volatile ConsumerWrapper consumerWrapper;
-
- private volatile boolean stopped;
-
- private final java.util.Queue tasks = new ConcurrentLinkedQueue<>();
-
- public TbRuleEngineQueueConsumerManager(TbRuleEngineConsumerContext ctx, QueueKey queueKey) {
+ @Builder(builderMethodName = "create") // not to conflict with super.builder()
+ public TbRuleEngineQueueConsumerManager(TbRuleEngineConsumerContext ctx,
+ QueueKey queueKey,
+ ExecutorService consumerExecutor,
+ ScheduledExecutorService scheduler,
+ ExecutorService taskExecutor) {
+ super(queueKey, null, null, ctx.getQueueFactory()::createToRuleEngineMsgConsumer, consumerExecutor, scheduler, taskExecutor);
this.ctx = ctx;
- this.queueKey = queueKey;
this.stats = new TbRuleEngineConsumerStats(queueKey, ctx.getStatsFactory());
}
- public void init(Queue queue) {
- this.queue = queue;
- if (queue.isConsumerPerPartition()) {
- this.consumerWrapper = new ConsumerPerPartitionWrapper();
- } else {
- this.consumerWrapper = new SingleConsumerWrapper();
- }
- log.debug("[{}] Initialized consumer for queue: {}", queueKey, queue);
- }
-
- public void update(Queue queue) {
- addTask(TbQueueConsumerManagerTask.configUpdate(queue));
- }
-
- public void update(Set partitions) {
- addTask(TbQueueConsumerManagerTask.partitionChange(partitions));
- }
-
public void delete(boolean drainQueue) {
addTask(TbQueueConsumerManagerTask.delete(drainQueue));
}
- private void addTask(TbQueueConsumerManagerTask todo) {
- if (stopped) {
- return;
- }
- tasks.add(todo);
- log.trace("[{}] Added task: {}", queueKey, todo);
- tryProcessTasks();
- }
-
- private void tryProcessTasks() {
- if (!ctx.isReady()) {
- log.debug("[{}] TbRuleEngineConsumerContext is not ready yet, will process tasks later", queueKey);
- ctx.getScheduler().schedule(this::tryProcessTasks, 1, TimeUnit.SECONDS);
- return;
- }
- ctx.getMgmtExecutor().submit(() -> {
- if (lock.tryLock()) {
- try {
- Queue newConfiguration = null;
- Set newPartitions = null;
- while (!stopped) {
- TbQueueConsumerManagerTask task = tasks.poll();
- if (task == null) {
- break;
- }
- log.trace("[{}] Processing task: {}", queueKey, task);
-
- if (task.getEvent() == QueueEvent.PARTITION_CHANGE) {
- newPartitions = task.getPartitions();
- } else if (task.getEvent() == QueueEvent.CONFIG_UPDATE) {
- newConfiguration = task.getQueue();
- } else if (task.getEvent() == QueueEvent.DELETE) {
- doDelete(task.isDrainQueue());
- return;
- }
- }
- if (stopped) {
- return;
- }
- if (newConfiguration != null) {
- doUpdate(newConfiguration);
- }
- if (newPartitions != null) {
- doUpdate(newPartitions);
- }
- } catch (Exception e) {
- log.error("[{}] Failed to process tasks", queueKey, e);
- } finally {
- lock.unlock();
- }
- } else {
- log.trace("[{}] Failed to acquire lock", queueKey);
- ctx.getScheduler().schedule(this::tryProcessTasks, 1, TimeUnit.SECONDS);
- }
- });
- }
-
- private void doUpdate(Queue newQueue) {
- log.info("[{}] Processing queue update: {}", queueKey, newQueue);
- var oldQueue = this.queue;
- this.queue = newQueue;
- if (log.isTraceEnabled()) {
- log.trace("[{}] Old queue configuration: {}", queueKey, oldQueue);
- log.trace("[{}] New queue configuration: {}", queueKey, newQueue);
- }
-
- if (oldQueue == null) {
- init(queue);
- } else if (newQueue.isConsumerPerPartition() != oldQueue.isConsumerPerPartition()) {
- consumerWrapper.getConsumers().forEach(TbQueueConsumerTask::initiateStop);
- consumerWrapper.getConsumers().forEach(TbQueueConsumerTask::awaitCompletion);
-
- init(queue);
- if (partitions != null) {
- doUpdate(partitions); // even if partitions number was changed, there can be no partition change event
- }
- } else {
- // do nothing, because partitions change (if they changed) will be handled on PartitionChangeEvent,
- // and changes to pollInterval/packProcessingTimeout/submitStrategy/processingStrategy will be picked up by consumer on the fly,
- // and queue topic and name are immutable
+ @Override
+ protected void processTask(TbQueueConsumerManagerTask task) {
+ if (task.getEvent() == QueueEvent.DELETE) {
+ doDelete(task.isDrainQueue());
}
}
- private void doUpdate(Set partitions) {
- this.partitions = partitions;
- consumerWrapper.updatePartitions(partitions);
- }
-
- public void stop() {
- log.debug("[{}] Stopping consumers", queueKey);
- consumerWrapper.getConsumers().forEach(TbQueueConsumerTask::initiateStop);
- stopped = true;
- }
-
- public void awaitStop() {
- consumerWrapper.getConsumers().forEach(TbQueueConsumerTask::awaitCompletion);
- log.debug("[{}] Unsubscribed and stopped consumers", queueKey);
- }
-
private void doDelete(boolean drainQueue) {
stopped = true;
log.info("[{}] Handling queue deletion", queueKey);
@@ -212,7 +92,7 @@ public class TbRuleEngineQueueConsumerManager {
List>> queueConsumers = consumerWrapper.getConsumers().stream()
.map(TbQueueConsumerTask::getConsumer).collect(Collectors.toList());
- ctx.getConsumersExecutor().submit(() -> {
+ consumerExecutor.submit(() -> {
if (drainQueue) {
drainQueue(queueConsumers);
}
@@ -235,47 +115,10 @@ public class TbRuleEngineQueueConsumerManager {
});
}
- private void launchConsumer(TbQueueConsumerTask consumerTask) {
- log.info("[{}] Launching consumer", consumerTask.getKey());
- Future> consumerLoop = ctx.getConsumersExecutor().submit(() -> {
- ThingsBoardThreadFactory.updateCurrentThreadName(consumerTask.getKey().toString());
- try {
- consumerLoop(consumerTask.getConsumer());
- } catch (Throwable e) {
- log.error("Failure in consumer loop", e);
- }
- });
- consumerTask.setTask(consumerLoop);
- }
-
- private void consumerLoop(TbQueueConsumer> consumer) {
- while (!stopped && !consumer.isStopped()) {
- try {
- List> msgs = consumer.poll(queue.getPollInterval());
- if (msgs.isEmpty()) {
- continue;
- }
- processMsgs(msgs, consumer, queue);
- } catch (Exception e) {
- if (!consumer.isStopped()) {
- log.warn("Failed to process messages from queue", e);
- try {
- Thread.sleep(ctx.getPollDuration());
- } catch (InterruptedException e2) {
- log.trace("Failed to wait until the server has capacity to handle new requests", e2);
- }
- }
- }
- }
- if (consumer.isStopped()) {
- consumer.unsubscribe();
- }
- log.info("Rule Engine consumer stopped");
- }
-
- private void processMsgs(List> msgs,
- TbQueueConsumer> consumer,
- Queue queue) throws InterruptedException {
+ @Override
+ protected void processMsgs(List> msgs,
+ TbQueueConsumer> consumer,
+ Queue queue) throws Exception {
TbRuleEngineSubmitStrategy submitStrategy = getSubmitStrategy(queue);
TbRuleEngineProcessingStrategy ackStrategy = getProcessingStrategy(queue);
submitStrategy.init(msgs);
@@ -320,7 +163,7 @@ public class TbRuleEngineQueueConsumerManager {
}
private void submitMessage(TbMsgPackProcessingContext packCtx, UUID id, TbProtoQueueMsg msg) {
- log.trace("[{}] Creating callback for topic {} message: {}", id, queue.getName(), msg.getValue());
+ log.trace("[{}] Creating callback for topic {} message: {}", id, config.getName(), msg.getValue());
ToRuleEngineMsg toRuleEngineMsg = msg.getValue();
TenantId tenantId = TenantId.fromUUID(new UUID(toRuleEngineMsg.getTenantIdMSB(), toRuleEngineMsg.getTenantIdLSB()));
TbMsgCallback callback = ctx.isPrometheusStatsEnabled() ?
@@ -328,7 +171,7 @@ public class TbRuleEngineQueueConsumerManager {
new TbMsgPackCallback(id, tenantId, packCtx);
try {
if (!toRuleEngineMsg.getTbMsg().isEmpty()) {
- forwardToRuleEngineActor(queue.getName(), tenantId, toRuleEngineMsg, callback);
+ forwardToRuleEngineActor(config.getName(), tenantId, toRuleEngineMsg, callback);
} else {
callback.onSuccess();
}
@@ -356,7 +199,7 @@ public class TbRuleEngineQueueConsumerManager {
log.info("[{}] {} to process [{}] messages", queueKey, prefix, map.size());
for (Map.Entry> pending : map.entrySet()) {
ToRuleEngineMsg tmp = pending.getValue().getValue();
- TbMsg tmpMsg = TbMsg.fromBytes(queue.getName(), tmp.getTbMsg().toByteArray(), TbMsgCallback.EMPTY);
+ TbMsg tmpMsg = TbMsg.fromBytes(config.getName(), tmp.getTbMsg().toByteArray(), TbMsgCallback.EMPTY);
RuleNodeInfo ruleNodeInfo = ctx.getLastVisitedRuleNode(pending.getKey());
if (printAll) {
log.trace("[{}][{}] {} to process message: {}, Last Rule Node: {}", queueKey, TenantId.fromUUID(new UUID(tmp.getTenantIdMSB(), tmp.getTenantIdLSB())), prefix, tmpMsg, ruleNodeInfo);
@@ -379,7 +222,7 @@ public class TbRuleEngineQueueConsumerManager {
int n = 0;
while (System.currentTimeMillis() <= finishTs) {
for (TbQueueConsumer> consumer : consumers) {
- List> msgs = consumer.poll(queue.getPollInterval());
+ List> msgs = consumer.poll(config.getPollInterval());
if (msgs.isEmpty()) {
continue;
}
@@ -388,7 +231,7 @@ public class TbRuleEngineQueueConsumerManager {
MsgProtos.TbMsgProto tbMsgProto = MsgProtos.TbMsgProto.parseFrom(msg.getValue().getTbMsg().toByteArray());
EntityId originator = EntityIdFactory.getByTypeAndUuid(tbMsgProto.getEntityType(), new UUID(tbMsgProto.getEntityIdMSB(), tbMsgProto.getEntityIdLSB()));
- TopicPartitionInfo tpi = ctx.getPartitionService().resolve(ServiceType.TB_RULE_ENGINE, queue.getName(), TenantId.SYS_TENANT_ID, originator);
+ TopicPartitionInfo tpi = ctx.getPartitionService().resolve(ServiceType.TB_RULE_ENGINE, config.getName(), TenantId.SYS_TENANT_ID, originator);
ctx.getProducerProvider().getRuleEngineMsgProducer().send(tpi, msg, null);
n++;
} catch (Throwable e) {
@@ -399,90 +242,11 @@ public class TbRuleEngineQueueConsumerManager {
}
}
if (n > 0) {
- log.info("Moved {} messages from {} to system {}", n, queueKey, queue.getName());
+ log.info("Moved {} messages from {} to system {}", n, queueKey, config.getName());
}
} catch (Exception e) {
log.error("[{}] Failed to drain queue", queueKey, e);
}
}
- private static String partitionsToString(Collection partitions) {
- return partitions.stream().map(TopicPartitionInfo::getFullTopicName).collect(Collectors.joining(", ", "[", "]"));
- }
-
- interface ConsumerWrapper {
-
- void updatePartitions(Set partitions);
-
- Collection getConsumers();
-
- }
-
- class ConsumerPerPartitionWrapper implements ConsumerWrapper {
- private final Map consumers = new HashMap<>();
-
- @Override
- public void updatePartitions(Set partitions) {
- Set addedPartitions = new HashSet<>(partitions);
- addedPartitions.removeAll(consumers.keySet());
-
- Set removedPartitions = new HashSet<>(consumers.keySet());
- removedPartitions.removeAll(partitions);
- log.info("[{}] Added partitions: {}, removed partitions: {}", queueKey, partitionsToString(addedPartitions), partitionsToString(removedPartitions));
-
- removedPartitions.forEach((tpi) -> {
- consumers.get(tpi).initiateStop();
- });
- removedPartitions.forEach((tpi) -> {
- consumers.remove(tpi).awaitCompletion();
- });
-
- addedPartitions.forEach((tpi) -> {
- String key = queueKey + "-" + tpi.getPartition().orElse(-999999);
- TbQueueConsumerTask consumer = new TbQueueConsumerTask(key, ctx.getQueueFactory().createToRuleEngineMsgConsumer(queue));
- consumers.put(tpi, consumer);
- consumer.subscribe(Set.of(tpi));
- launchConsumer(consumer);
- });
- }
-
- @Override
- public Collection getConsumers() {
- return consumers.values();
- }
- }
-
- class SingleConsumerWrapper implements ConsumerWrapper {
- private TbQueueConsumerTask consumer;
-
- @Override
- public void updatePartitions(Set partitions) {
- log.info("[{}] New partitions: {}", queueKey, partitionsToString(partitions));
- if (partitions.isEmpty()) {
- if (consumer != null && consumer.isRunning()) {
- consumer.initiateStop();
- consumer.awaitCompletion();
- }
- consumer = null;
- return;
- }
-
- if (consumer == null) {
- consumer = new TbQueueConsumerTask(queueKey, ctx.getQueueFactory().createToRuleEngineMsgConsumer(queue));
- }
- consumer.subscribe(partitions);
- if (!consumer.isRunning()) {
- launchConsumer(consumer);
- }
- }
-
- @Override
- public Collection getConsumers() {
- if (consumer == null) {
- return Collections.emptyList();
- }
- return List.of(consumer);
- }
- }
-
}
diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsService.java
index e5143a6023..c5c04ac312 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsService.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsService.java
@@ -17,25 +17,24 @@ package org.thingsboard.server.service.security.auth.jwt.settings;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.apache.commons.lang3.RandomStringUtils;
-import org.springframework.beans.factory.annotation.Value;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.thingsboard.common.util.JacksonUtil;
-import org.thingsboard.rule.engine.api.NotificationCenter;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.AdminSettings;
import org.thingsboard.server.common.data.id.TenantId;
-import org.thingsboard.server.common.data.notification.targets.platform.SystemAdministratorsFilter;
import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent;
import org.thingsboard.server.common.data.security.model.JwtSettings;
-import org.thingsboard.server.dao.notification.DefaultNotifications;
import org.thingsboard.server.dao.settings.AdminSettingsService;
+import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
-import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Objects;
import java.util.Optional;
+import static org.thingsboard.server.service.security.model.token.JwtTokenFactory.KEY_LENGTH;
+
@Service
@RequiredArgsConstructor
@Slf4j
@@ -43,49 +42,11 @@ public class DefaultJwtSettingsService implements JwtSettingsService {
private final AdminSettingsService adminSettingsService;
private final Optional tbClusterService;
- private final Optional notificationCenter;
private final JwtSettingsValidator jwtSettingsValidator;
-
- @Value("${security.jwt.tokenExpirationTime:9000}")
- private Integer tokenExpirationTime;
- @Value("${security.jwt.refreshTokenExpTime:604800}")
- private Integer refreshTokenExpTime;
- @Value("${security.jwt.tokenIssuer:thingsboard.io}")
- private String tokenIssuer;
- @Value("${security.jwt.tokenSigningKey:thingsboardDefaultSigningKey}")
- private String tokenSigningKey;
+ private final Optional jwtTokenFactory;
private volatile JwtSettings jwtSettings = null; //lazy init
- /**
- * Create JWT admin settings is intended to be called from Install scripts only
- */
- @Override
- public void createRandomJwtSettings() {
- if (getJwtSettingsFromDb() == null) {
- log.info("Creating JWT admin settings...");
- this.jwtSettings = getJwtSettingsFromYml();
- if (isSigningKeyDefault(jwtSettings)) {
- this.jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(
- RandomStringUtils.randomAlphanumeric(64).getBytes(StandardCharsets.UTF_8)));
- }
- saveJwtSettings(jwtSettings);
- } else {
- log.info("Skip creating JWT admin settings because they already exist.");
- }
- }
-
- /**
- * Create JWT admin settings is intended to be called from Upgrade scripts only
- */
- @Override
- public void saveLegacyYmlSettings() {
- log.info("Saving legacy JWT admin settings from YML...");
- if (getJwtSettingsFromDb() == null) {
- saveJwtSettings(getJwtSettingsFromYml());
- }
- }
-
@Override
public JwtSettings saveJwtSettings(JwtSettings jwtSettings) {
jwtSettingsValidator.validate(jwtSettings);
@@ -105,7 +66,9 @@ public class DefaultJwtSettingsService implements JwtSettingsService {
@Override
public JwtSettings reloadJwtSettings() {
log.trace("Executing reloadJwtSettings");
- return getJwtSettings(true);
+ var settings = getJwtSettings(true);
+ jwtTokenFactory.ifPresent(JwtTokenFactory::reload);
+ return settings;
}
@Override
@@ -118,30 +81,13 @@ public class DefaultJwtSettingsService implements JwtSettingsService {
if (this.jwtSettings == null || forceReload) {
synchronized (this) {
if (this.jwtSettings == null || forceReload) {
- JwtSettings result = getJwtSettingsFromDb();
- if (result == null) {
- result = getJwtSettingsFromYml();
- log.warn("Loading the JWT settings from YML since there are no settings in DB. Looks like the upgrade script was not applied.");
- }
- if (isSigningKeyDefault(result)) {
- log.warn("WARNING: The platform is configured to use default JWT Signing Key. " +
- "This is a security issue that needs to be resolved. Please change the JWT Signing Key using the Web UI. " +
- "Navigate to \"System settings -> Security settings\" while logged in as a System Administrator.");
- notificationCenter.ifPresent(notificationCenter -> {
- notificationCenter.sendGeneralWebNotification(TenantId.SYS_TENANT_ID, new SystemAdministratorsFilter(), DefaultNotifications.jwtSigningKeyIssue.toTemplate());
- });
- }
- this.jwtSettings = result;
+ jwtSettings = getJwtSettingsFromDb();
}
}
}
return this.jwtSettings;
}
- private JwtSettings getJwtSettingsFromYml() {
- return new JwtSettings(this.tokenExpirationTime, this.refreshTokenExpTime, this.tokenIssuer, this.tokenSigningKey);
- }
-
private JwtSettings getJwtSettingsFromDb() {
AdminSettings adminJwtSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, ADMIN_SETTINGS_JWT_KEY);
return adminJwtSettings != null ? mapAdminToJwtSettings(adminJwtSettings) : null;
@@ -161,8 +107,12 @@ public class DefaultJwtSettingsService implements JwtSettingsService {
return adminJwtSettings;
}
- private boolean isSigningKeyDefault(JwtSettings settings) {
+ public static boolean isSigningKeyDefault(JwtSettings settings) {
return TOKEN_SIGNING_KEY_DEFAULT.equals(settings.getTokenSigningKey());
}
+ public static boolean validateTokenSigningKeyLength(JwtSettings settings) {
+ return Base64.getDecoder().decode(settings.getTokenSigningKey()).length * Byte.SIZE >= KEY_LENGTH;
+ }
+
}
diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java
index b807f65d36..ea8c67e3dc 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/DefaultJwtSettingsValidator.java
@@ -27,6 +27,9 @@ import java.util.Base64;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
+import static org.thingsboard.server.service.security.auth.jwt.settings.DefaultJwtSettingsService.isSigningKeyDefault;
+import static org.thingsboard.server.service.security.model.token.JwtTokenFactory.KEY_LENGTH;
+
@Component
@RequiredArgsConstructor
public class DefaultJwtSettingsValidator implements JwtSettingsValidator {
@@ -59,8 +62,8 @@ public class DefaultJwtSettingsValidator implements JwtSettingsValidator {
if (Arrays.isNullOrEmpty(decodedKey)) {
throw new DataValidationException("JWT token signing key should be non-empty after Base64 decoding!");
}
- if (decodedKey.length * Byte.SIZE < 256 && !JwtSettingsService.TOKEN_SIGNING_KEY_DEFAULT.equals(jwtSettings.getTokenSigningKey())) {
- throw new DataValidationException("JWT token signing key should be a Base64 encoded string representing at least 256 bits of data!");
+ if (decodedKey.length * Byte.SIZE < KEY_LENGTH && !isSigningKeyDefault(jwtSettings)) {
+ throw new DataValidationException("JWT token signing key should be a Base64 encoded string representing at least 512 bits of data!");
}
System.arraycopy(decodedKey, 0, RandomUtils.nextBytes(decodedKey.length), 0, decodedKey.length); //secure memory
diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java
index 19095ad0b9..d3aa261b00 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/auth/jwt/settings/JwtSettingsService.java
@@ -26,10 +26,6 @@ public interface JwtSettingsService {
JwtSettings reloadJwtSettings();
- void createRandomJwtSettings();
-
- void saveLegacyYmlSettings();
-
JwtSettings saveJwtSettings(JwtSettings jwtSettings);
}
diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java
index 08622f578a..f2e276a0ef 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/JwtTokenFactory.java
@@ -16,16 +16,19 @@
package org.thingsboard.server.service.security.model.token;
import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ClaimsBuilder;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.JwtBuilder;
+import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
-import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;
+import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Lazy;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.stereotype.Component;
@@ -41,7 +44,10 @@ import org.thingsboard.server.service.security.exception.JwtExpiredTokenExceptio
import org.thingsboard.server.service.security.model.SecurityUser;
import org.thingsboard.server.service.security.model.UserPrincipal;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
import java.time.ZonedDateTime;
+import java.util.Base64;
import java.util.Collections;
import java.util.Date;
import java.util.List;
@@ -53,6 +59,8 @@ import java.util.stream.Collectors;
@Slf4j
public class JwtTokenFactory {
+ public static int KEY_LENGTH = Jwts.SIG.HS512.getKeyBitLength();
+
private static final String SCOPES = "scopes";
private static final String USER_ID = "userId";
private static final String FIRST_NAME = "firstName";
@@ -63,8 +71,12 @@ public class JwtTokenFactory {
private static final String CUSTOMER_ID = "customerId";
private static final String SESSION_ID = "sessionId";
+ @Lazy
private final JwtSettingsService jwtSettingsService;
+ private volatile JwtParser jwtParser;
+ private volatile SecretKey secretKey;
+
/**
* Factory method for issuing new JWT Tokens.
*/
@@ -95,7 +107,7 @@ public class JwtTokenFactory {
public SecurityUser parseAccessJwtToken(String token) {
Jws jwsClaims = parseTokenClaims(token);
- Claims claims = jwsClaims.getBody();
+ Claims claims = jwsClaims.getPayload();
String subject = claims.getSubject();
@SuppressWarnings("unchecked")
List scopes = claims.get(SCOPES, List.class);
@@ -140,14 +152,14 @@ public class JwtTokenFactory {
String token = setUpToken(securityUser, Collections.singletonList(Authority.REFRESH_TOKEN.name()), jwtSettingsService.getJwtSettings().getRefreshTokenExpTime())
.claim(IS_PUBLIC, principal.getType() == UserPrincipal.Type.PUBLIC_ID)
- .setId(UUID.randomUUID().toString()).compact();
+ .id(UUID.randomUUID().toString()).compact();
return new AccessJwtToken(token);
}
public SecurityUser parseRefreshToken(String token) {
Jws jwsClaims = parseTokenClaims(token);
- Claims claims = jwsClaims.getBody();
+ Claims claims = jwsClaims.getPayload();
String subject = claims.getSubject();
@SuppressWarnings("unchecked")
List scopes = claims.get(SCOPES, List.class);
@@ -176,6 +188,11 @@ public class JwtTokenFactory {
return new AccessJwtToken(jwtBuilder.compact());
}
+ public void reload() {
+ getSecretKey(true);
+ getJwtParser(true);
+ }
+
private JwtBuilder setUpToken(SecurityUser securityUser, List scopes, long expirationTime) {
if (StringUtils.isBlank(securityUser.getEmail())) {
throw new IllegalArgumentException("Cannot create JWT Token without username/email");
@@ -183,28 +200,27 @@ public class JwtTokenFactory {
UserPrincipal principal = securityUser.getUserPrincipal();
- Claims claims = Jwts.claims().setSubject(principal.getValue());
- claims.put(USER_ID, securityUser.getId().getId().toString());
- claims.put(SCOPES, scopes);
+ ClaimsBuilder claimsBuilder = Jwts.claims()
+ .subject(principal.getValue())
+ .add(USER_ID, securityUser.getId().getId().toString())
+ .add(SCOPES, scopes);
if (securityUser.getSessionId() != null) {
- claims.put(SESSION_ID, securityUser.getSessionId());
+ claimsBuilder.add(SESSION_ID, securityUser.getSessionId());
}
ZonedDateTime currentTime = ZonedDateTime.now();
return Jwts.builder()
- .setClaims(claims)
- .setIssuer(jwtSettingsService.getJwtSettings().getTokenIssuer())
- .setIssuedAt(Date.from(currentTime.toInstant()))
- .setExpiration(Date.from(currentTime.plusSeconds(expirationTime).toInstant()))
- .signWith(SignatureAlgorithm.HS512, jwtSettingsService.getJwtSettings().getTokenSigningKey());
+ .claims(claimsBuilder.build())
+ .issuer(jwtSettingsService.getJwtSettings().getTokenIssuer())
+ .issuedAt(Date.from(currentTime.toInstant()))
+ .expiration(Date.from(currentTime.plusSeconds(expirationTime).toInstant()))
+ .signWith(getSecretKey(false), Jwts.SIG.HS512);
}
public Jws parseTokenClaims(String token) {
try {
- return Jwts.parser()
- .setSigningKey(jwtSettingsService.getJwtSettings().getTokenSigningKey())
- .parseClaimsJws(token);
+ return getJwtParser(false).parseSignedClaims(token);
} catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException ex) {
log.debug("Invalid JWT Token", ex);
throw new BadCredentialsException("Invalid JWT token: ", ex);
@@ -220,4 +236,28 @@ public class JwtTokenFactory {
return new JwtPair(accessToken.getToken(), refreshToken.getToken());
}
+ private SecretKey getSecretKey(boolean forceReload) {
+ if (secretKey == null || forceReload) {
+ synchronized (this) {
+ if (secretKey == null || forceReload) {
+ byte[] decodedToken = Base64.getDecoder().decode(jwtSettingsService.getJwtSettings().getTokenSigningKey());
+ secretKey = new SecretKeySpec(decodedToken, "HmacSHA512");
+ }
+ }
+ }
+ return secretKey;
+ }
+
+ private JwtParser getJwtParser(boolean forceReload) {
+ if (jwtParser == null || forceReload) {
+ synchronized (this) {
+ if (jwtParser == null || forceReload) {
+ jwtParser = Jwts.parser()
+ .verifyWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(jwtSettingsService.getJwtSettings().getTokenSigningKey())))
+ .build();
+ }
+ }
+ }
+ return jwtParser;
+ }
}
diff --git a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java
index 7f956f6970..cf01dec208 100644
--- a/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java
+++ b/application/src/main/java/org/thingsboard/server/service/security/model/token/OAuth2AppTokenFactory.java
@@ -22,10 +22,12 @@ import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.SignatureException;
import io.jsonwebtoken.UnsupportedJwtException;
+import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.thingsboard.server.common.data.StringUtils;
+import java.util.Base64;
import java.util.Date;
import java.util.concurrent.TimeUnit;
@@ -40,14 +42,14 @@ public class OAuth2AppTokenFactory {
public String validateTokenAndGetCallbackUrlScheme(String appPackage, String appToken, String appSecret) {
Jws jwsClaims;
try {
- jwsClaims = Jwts.parser().setSigningKey(appSecret).parseClaimsJws(appToken);
+ jwsClaims = Jwts.parser().verifyWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(appSecret))).build().parseSignedClaims(appToken);
}
catch (UnsupportedJwtException | MalformedJwtException | IllegalArgumentException | SignatureException ex) {
throw new IllegalArgumentException("Invalid Application token: ", ex);
} catch (ExpiredJwtException expiredEx) {
throw new IllegalArgumentException("Application token expired", expiredEx);
}
- Claims claims = jwsClaims.getBody();
+ Claims claims = jwsClaims.getPayload();
Date expiration = claims.getExpiration();
if (expiration == null) {
throw new IllegalArgumentException("Application token must have expiration date");
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/DefaultExportableEntitiesService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/DefaultExportableEntitiesService.java
index 49314e62fd..9779fad66a 100644
--- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/DefaultExportableEntitiesService.java
+++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/DefaultExportableEntitiesService.java
@@ -108,34 +108,40 @@ public class DefaultExportableEntitiesService implements ExportableEntitiesServi
return entity;
}
+ @Override
+ public , I extends EntityId> E findDefaultEntityByTenantId(TenantId tenantId, EntityType entityType) {
+ ExportableEntityDao dao = getExportableEntityDao(entityType);
+ if (dao == null) {
+ return null;
+ }
+ return dao.findDefaultEntityByTenantId(tenantId.getId());
+ }
+
@Override
public , I extends EntityId> PageData findEntitiesByTenantId(TenantId tenantId, EntityType entityType, PageLink pageLink) {
ExportableEntityDao dao = getExportableEntityDao(entityType);
- if (dao != null) {
- return dao.findByTenantId(tenantId.getId(), pageLink);
- } else {
- return new PageData<>();
+ if (dao == null) {
+ return PageData.emptyPageData();
}
+ return dao.findByTenantId(tenantId.getId(), pageLink);
}
@Override
public PageData findEntitiesIdsByTenantId(TenantId tenantId, EntityType entityType, PageLink pageLink) {
ExportableEntityDao dao = getExportableEntityDao(entityType);
- if (dao != null) {
- return dao.findIdsByTenantId(tenantId.getId(), pageLink);
- } else {
- return new PageData<>();
+ if (dao == null) {
+ return PageData.emptyPageData();
}
+ return dao.findIdsByTenantId(tenantId.getId(), pageLink);
}
@Override
public I getExternalIdByInternal(I internalId) {
ExportableEntityDao dao = getExportableEntityDao(internalId.getEntityType());
- if (dao != null) {
- return dao.getExternalIdByInternal(internalId);
- } else {
+ if (dao == null) {
return null;
}
+ return dao.getExternalIdByInternal(internalId);
}
private boolean belongsToTenant(HasId extends EntityId> entity, TenantId tenantId) {
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/ExportableEntitiesService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/ExportableEntitiesService.java
index 5eadec9410..4d726ec44a 100644
--- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/ExportableEntitiesService.java
+++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/ExportableEntitiesService.java
@@ -33,6 +33,8 @@ public interface ExportableEntitiesService {
, I extends EntityId> E findEntityByTenantIdAndName(TenantId tenantId, EntityType entityType, String name);
+ , I extends EntityId> E findDefaultEntityByTenantId(TenantId tenantId, EntityType entityType);
+
, I extends EntityId> PageData findEntitiesByTenantId(TenantId tenantId, EntityType entityType, PageLink pageLink);
PageData findEntitiesIdsByTenantId(TenantId tenantId, EntityType entityType, PageLink pageLink);
diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java
index cf05a35f33..e144fed6c6 100644
--- a/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java
+++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/importing/impl/BaseEntityImportService.java
@@ -27,6 +27,7 @@ import org.thingsboard.common.util.JacksonUtil;
import org.thingsboard.server.cluster.TbClusterService;
import org.thingsboard.server.common.data.EntityType;
import org.thingsboard.server.common.data.ExportableEntity;
+import org.thingsboard.server.common.data.HasDefaultOption;
import org.thingsboard.server.common.data.User;
import org.thingsboard.server.common.data.audit.ActionType;
import org.thingsboard.server.common.data.exception.ThingsboardException;
@@ -73,7 +74,7 @@ public abstract class BaseEntityImportService Optional.ofNullable(exportableEntitiesService.findEntityByTenantIdAndId(ctx.getTenantId(), entity.getId())))
+ return (E) Optional.ofNullable(entitiesService.findEntityByTenantIdAndExternalId(ctx.getTenantId(), entity.getId()))
+ .or(() -> Optional.ofNullable(entitiesService.findEntityByTenantIdAndId(ctx.getTenantId(), entity.getId())))
.or(() -> {
if (ctx.isFindExistingByName()) {
- return Optional.ofNullable(exportableEntitiesService.findEntityByTenantIdAndName(ctx.getTenantId(), getEntityType(), entity.getName()));
+ return Optional.ofNullable(entitiesService.findEntityByTenantIdAndName(ctx.getTenantId(), getEntityType(), entity.getName()));
} else {
return Optional.empty();
}
})
+ .or(() -> {
+ if (entity instanceof HasDefaultOption hasDefaultOption) {
+ if (hasDefaultOption.isDefault()) {
+ return Optional.ofNullable(entitiesService.findDefaultEntityByTenantId(ctx.getTenantId(), getEntityType()));
+ }
+ }
+ return Optional.empty();
+ })
.orElse(null);
}
@SuppressWarnings("unchecked")
private HasId findInternalEntity(TenantId tenantId, ID externalId) {
- return (HasId) Optional.ofNullable(exportableEntitiesService.findEntityByTenantIdAndExternalId(tenantId, externalId))
- .or(() -> Optional.ofNullable(exportableEntitiesService.findEntityByTenantIdAndId(tenantId, externalId)))
+ return (HasId) Optional.ofNullable(entitiesService.findEntityByTenantIdAndExternalId(tenantId, externalId))
+ .or(() -> Optional.ofNullable(entitiesService.findEntityByTenantIdAndId(tenantId, externalId)))
.orElseThrow(() -> new MissingEntityException(externalId));
}
diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml
index 6047bbfb16..290d57a468 100644
--- a/application/src/main/resources/thingsboard.yml
+++ b/application/src/main/resources/thingsboard.yml
@@ -164,6 +164,8 @@ mail:
oauth2:
# Interval for checking refresh token expiration in seconds(by default, 1 day).
refreshTokenCheckingInterval: "${REFRESH_TOKEN_EXPIRATION_CHECKING_INTERVAL:86400}"
+ # Rate limits for sending mails per tenant. As example for 1000 per minute and 10000 per hour is "1000:60,10000:3600"
+ per_tenant_rate_limits: "${MAIL_PER_TENANT_RATE_LIMITS:}"
# Usage statistics parameters
usage:
@@ -505,6 +507,12 @@ cache:
assets:
timeToLiveInMinutes: "${CACHE_SPECS_ASSETS_TTL:1440}" # Asset cache TTL
maxSize: "${CACHE_SPECS_ASSETS_MAX_SIZE:10000}" # 0 means the cache is disabled
+ customers:
+ timeToLiveInMinutes: "${CACHE_SPECS_CUSTOMERS_TTL:1440}" # Customer cache TTL
+ maxSize: "${CACHE_SPECS_CUSTOMERS_MAX_SIZE:10000}" # 0 means the cache is disabled
+ users:
+ timeToLiveInMinutes: "${CACHE_SPECS_USERS_TTL:1440}" # User cache TTL
+ maxSize: "${CACHE_SPECS_USERS_MAX_SIZE:10000}" # 0 means the cache is disabled
entityViews:
timeToLiveInMinutes: "${CACHE_SPECS_ENTITY_VIEWS_TTL:1440}" # Entity view cache TTL
maxSize: "${CACHE_SPECS_ENTITY_VIEWS_MAX_SIZE:10000}" # 0 means the cache is disabled
@@ -1621,6 +1629,8 @@ queue:
partitions: "${TB_QUEUE_CORE_PARTITIONS:10}"
# Timeout for processing a message pack by Core microservices
pack-processing-timeout: "${TB_QUEUE_CORE_PACK_PROCESSING_TIMEOUT_MS:2000}"
+ # Enable/disable a separate consumer per partition for Core queue
+ consumer-per-partition: "${TB_QUEUE_CORE_CONSUMER_PER_PARTITION:true}"
ota:
# Default topic name for OTA updates
topic: "${TB_QUEUE_CORE_OTA_TOPIC:tb_ota_package}"
diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
index 96e52d6c12..b83a1b567c 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java
@@ -21,9 +21,7 @@ import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import io.jsonwebtoken.Claims;
-import io.jsonwebtoken.Header;
-import io.jsonwebtoken.Jwt;
-import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.Jws;
import lombok.extern.slf4j.Slf4j;
import org.awaitility.Awaitility;
import org.hamcrest.Matcher;
@@ -121,6 +119,7 @@ import org.thingsboard.server.queue.memory.InMemoryStorage;
import org.thingsboard.server.service.entitiy.tenant.profile.TbTenantProfileService;
import org.thingsboard.server.service.security.auth.jwt.RefreshTokenRequest;
import org.thingsboard.server.service.security.auth.rest.LoginRequest;
+import org.thingsboard.server.service.security.model.token.JwtTokenFactory;
import java.io.IOException;
import java.lang.invoke.MethodHandles;
@@ -241,6 +240,9 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
@Autowired
protected ClaimDevicesService claimDevicesService;
+ @Autowired
+ private JwtTokenFactory jwtTokenFactory;
+
@SpyBean
protected MailService mailService;
@@ -561,13 +563,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest {
}
protected void validateJwtToken(String token, String username) {
- Assert.assertNotNull(token);
- Assert.assertFalse(token.isEmpty());
- int i = token.lastIndexOf('.');
- Assert.assertTrue(i > 0);
- String withoutSignature = token.substring(0, i + 1);
- Jwt jwsClaims = Jwts.parser().parseClaimsJwt(withoutSignature);
- Claims claims = jwsClaims.getBody();
+ Jws jwsClaims = jwtTokenFactory.parseTokenClaims(token);
+ Claims claims = jwsClaims.getPayload();
String subject = claims.getSubject();
Assert.assertEquals(username, subject);
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java
index 430ff2eb09..8876bf8c24 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AdminControllerTest.java
@@ -42,7 +42,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
@Slf4j
@DaoSqlTest
public class AdminControllerTest extends AbstractControllerTest {
- final JwtSettings defaultJwtSettings = new JwtSettings(9000, 604800, "thingsboard.io", "thingsboardDefaultSigningKey");
+ final JwtSettings defaultJwtSettings = new JwtSettings(9000, 604800, "thingsboard.io", "QmlicmJkZk9tSzZPVFozcWY0Sm94UVhybmtBWXZ5YmZMOUZSZzZvcUFiOVhsb3VHUThhUWJGaXp3UHhtcGZ6Tw==");
@Test
public void testFindAdminSettingsByKey() throws Exception {
@@ -168,7 +168,7 @@ public class AdminControllerTest extends AbstractControllerTest {
assertThat(jwtSettings).isEqualTo(defaultJwtSettings);
jwtSettings.setTokenSigningKey(Base64.getEncoder().encodeToString(
- RandomStringUtils.randomAlphanumeric(256 / Byte.SIZE).getBytes(StandardCharsets.UTF_8)));
+ RandomStringUtils.randomAlphanumeric(512 / Byte.SIZE).getBytes(StandardCharsets.UTF_8)));
doPost("/api/admin/jwtSettings", jwtSettings).andExpect(status().isOk());
diff --git a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java
index 4e0b7299fe..31f398dc03 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java
@@ -216,11 +216,12 @@ public class AuditLogControllerTest extends AbstractControllerTest {
@Test
public void whenSavingAuditLogAndPartitionSaveErrorOccurred_thenSaveAuditLogAnyway() throws Exception {
// creating partition bigger than sql.audit_logs.partition_size
- long entityTs = ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2024-04-29T07:43:11Z").getTime();
+ long entityTs = ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2022-04-29T07:43:11Z").getTime();
+ //the partition 7 days is overlapping default partition size 1 day, use in the far past to not affect other tests
partitioningRepository.createPartitionIfNotExists("audit_log", entityTs, TimeUnit.DAYS.toMillis(7));
List partitions = partitioningRepository.fetchPartitions("audit_log");
log.warn("entityTs [{}], fetched partitions {}", entityTs, partitions);
- assertThat(partitions).contains(ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2024-04-29T00:00:00Z").getTime());
+ assertThat(partitions).contains(ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2022-04-28T00:00:00Z").getTime());
partitioningRepository.cleanupPartitionsCache("audit_log", entityTs, 0);
assertDoesNotThrow(() -> {
@@ -228,7 +229,7 @@ public class AuditLogControllerTest extends AbstractControllerTest {
createAuditLog(ActionType.LOGIN, tenantAdminUserId, entityTs);
});
assertThat(partitioningRepository.fetchPartitions("audit_log"))
- .contains(ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2024-04-29T00:00:00Z").getTime());;
+ .contains(ISO_8601_EXTENDED_DATETIME_TIME_ZONE_FORMAT.parse("2022-04-28T00:00:00Z").getTime());;
}
private AuditLog createAuditLog(ActionType actionType, EntityId entityId, long entityTs) {
diff --git a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java
index 5365c9fbec..1cf389a93d 100644
--- a/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/AuthControllerTest.java
@@ -167,4 +167,10 @@ public class AuthControllerTest extends AbstractControllerTest {
.andExpect(jsonPath("$.message",
is("Password must be no more than 72 characters in length.")));
}
+
+ @Test
+ public void testGetPageWithoutRedirect() throws Exception {
+ doGet("/login").andExpect(status().isOk());
+ doGet("/home").andExpect(status().isOk());
+ }
}
diff --git a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
index f431f9ac57..7823838ee8 100644
--- a/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/CustomerControllerTest.java
@@ -352,7 +352,7 @@ public class CustomerControllerTest extends AbstractControllerTest {
}
@Test
- public void testFindCustomersByTitle() throws Exception {
+ public void testFindCustomersWithTitleAsTextSearch() throws Exception {
TenantId tenantId = savedTenant.getId();
String title1 = "Customer title 1";
@@ -424,6 +424,31 @@ public class CustomerControllerTest extends AbstractControllerTest {
Assert.assertEquals(0, pageData.getData().size());
}
+ @Test
+ public void testFindCustomerByTitle() throws Exception {
+ Customer customer = new Customer();
+ customer.setTitle("My customer");
+
+ Mockito.reset(tbClusterService, auditLogService);
+
+ Customer savedCustomer = doPost("/api/customer", customer, Customer.class);
+
+ testNotifyEntityAllOneTime(savedCustomer, savedCustomer.getId(), savedCustomer.getId(), savedCustomer.getTenantId(),
+ new CustomerId(CustomerId.NULL_UUID), tenantAdmin.getId(), tenantAdmin.getEmail(),
+ ActionType.ADDED);
+
+ Assert.assertNotNull(savedCustomer);
+ Assert.assertNotNull(savedCustomer.getId());
+ Assert.assertTrue(savedCustomer.getCreatedTime() > 0);
+ Assert.assertEquals(customer.getTitle(), savedCustomer.getTitle());
+
+ Customer foundCustomer = doGet("/api/tenant/customers?customerTitle=" + savedCustomer.getTitle(), Customer.class);
+ Assert.assertEquals(foundCustomer, savedCustomer);
+
+ doDelete("/api/customer/" + savedCustomer.getId().getId().toString())
+ .andExpect(status().isOk());
+ }
+
@Test
public void testDeleteCustomerWithDeleteRelationsOk() throws Exception {
CustomerId customerId = createCustomer("Customer for Test WithRelationsOk").getId();
diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
index 7e62d9d326..512dd434dc 100644
--- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
+++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java
@@ -84,6 +84,7 @@ import java.util.Map;
import java.util.concurrent.TimeUnit;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.awaitility.Awaitility.await;
import static org.hamcrest.Matchers.containsString;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
@@ -1541,17 +1542,24 @@ public class DeviceControllerTest extends AbstractControllerTest {
Device savedDevice = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class);
//check server attribute value
- List