diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md index f5d093f424..a126a28e34 100644 --- a/.github/ISSUE_TEMPLATE/---bug-report.md +++ b/.github/ISSUE_TEMPLATE/---bug-report.md @@ -12,8 +12,8 @@ A clear and concise description of what the bug is. **Your Server Environment** -* [https://demo.thingsboard.io](demo.thingsboard.io) -* [https://thingsboard.cloud](thingsboard.cloud) +* [Live Demo](https://demo.thingsboard.io) +* [ThingsBoard Cloud](https://thingsboard.cloud) * own setup * Deployment: monolith or microservices * Deployment type: deb, rpm, exe, docker-compose, k8s, ami @@ -60,3 +60,8 @@ If applicable, please add screenshots to help explain your problem. **Additional context** Please feel free to add any other context about the problem here. + +________________________________________________________________ +**Disclaimer** + +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 1168adaf15..9430e961d5 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -18,3 +18,8 @@ A clear and concise description of any alternative solutions or features you've **Additional context** Add any other context or screenshots about the feature request here. + +_____________________________________________________ +**Disclaimer** + +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies)), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index dea6318ac5..7f4becfe82 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -23,3 +23,8 @@ Clear and concise details. * OS: name and version * ThingsBoard: version * Browser: name and version + +___________________________________________________________ +**Disclaimer** + +We appreciate your contribution whether it is a bug report, feature request, or pull request with improvement (hopefully). Please comply with the [Community ethics policy](https://docs.github.com/en/site-policy/acceptable-use-policies/github-acceptable-use-policies), and do not expect us to answer your requests immediately. Also, do not treat *GitHub issues* as a support channel. diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 39996839f1..e0f4f4c615 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -17,6 +17,7 @@ "cards.label_widget", "cards.dashboard_state_widget", "cards.qr_code", + "mobile_app_qr_code", "cards.attributes_card", "cards.html_card", "cards.html_value_card", 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 12716482c9..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.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 };\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 fe5722a01a..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.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 };\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 35cb457590..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.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 };\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_table.json b/application/src/main/data/json/system/widget_types/entities_table.json index 73a546cb87..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.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 };\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/mobile_app_qr_code.json b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json new file mode 100644 index 0000000000..37f7df0ae6 --- /dev/null +++ b/application/src/main/data/json/system/widget_types/mobile_app_qr_code.json @@ -0,0 +1,26 @@ +{ + "fqn": "mobile_app_qr_code", + "name": "Mobile app QR code", + "deprecated": false, + "image": "tb-image:bW9iaWxlX2FwcF9xcl9jb2RlX3dpZGdldC5zdmc=:bW9iaWxlX2FwcF9xcl9jb2RlX3dpZGdldC5zdmc=;data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMDAiIGhlaWdodD0iMTYwIiBmaWxsPSJub25lIj48cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjE2MCIgZmlsbD0iI2ZmZiIgcng9IjQiLz48cmVjdCB3aWR0aD0iMTk5IiBoZWlnaHQ9IjE1OSIgeD0iLjUiIHk9Ii41IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1vcGFjaXR5PSIuMSIgcng9IjMuNSIvPjxnIGNsaXAtcGF0aD0idXJsKCNhKSI+PHJlY3Qgd2lkdGg9IjEzNyIgaGVpZ2h0PSIxMzciIHg9IjMxLjUiIHk9IjExLjUiIGZpbGw9IiNmZmYiIHJ4PSI4LjIiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTY4LjUgMTEuNWgtMTM3djEzN2gxMzd2LTEzN1oiLz48bWFzayBpZD0iYiIgd2lkdGg9IjEyNCIgaGVpZ2h0PSIxMjQiIHg9IjM4IiB5PSIxOCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDUyLjRoLTQuM3Y0LjJoNC4zdi00LjJaTTQyLjcgNjAuOWgtNC4zVjY1aDQuM1Y2MVpNNDIuNyA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNNDIuNyA5NC45aC00LjNWOTloNC4zVjk1Wk00NyA1Ni42aC00LjNWNjFoNC4ydi00LjNaTTQ3IDgyLjFoLTQuM3Y0LjNoNC4yVjgyWk00NyA4Ni40aC00LjN2NC4yaDQuMnYtNC4yWk00NyA5NC45aC00LjNWOTloNC4yVjk1Wk00NyAxMDMuNGgtNC4zdjQuMmg0LjJ2LTQuMlpNNTEuMiA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDYwLjloLTQuM1Y2NWg0LjNWNjFaTTUxLjIgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDc3LjloLTQuM1Y4Mmg0LjNWNzhaTTUxLjIgODIuMWgtNC4zdjQuM2g0LjNWODJaTTUxLjIgOTAuNmgtNC4zVjk1aDQuM3YtNC4zWk01MS4yIDk5LjFoLTQuM3Y0LjNoNC4zVjk5Wk01NS40IDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTU1LjQgNjkuNGgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCA4Ni40aC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDk5LjFoLTQuMnY0LjNoNC4yVjk5Wk01NS40IDEwMy40aC00LjJ2NC4yaDQuMnYtNC4yWk01OS43IDY1LjFoLTQuM3Y0LjNoNC4zVjY1Wk01OS43IDY5LjRoLTQuM3Y0LjJoNC4zdi00LjJaTTU5LjcgODYuNGgtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTkuNyA5MC42aC00LjNWOTVoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU5LjcgOTQuOWgtNC4zVjk5aDQuM1Y5NVpNNjMuOSA1Mi40aC00LjJ2NC4yaDQuMnYtNC4yWk02My45IDY1LjFoLTQuMnY0LjNoNC4yVjY1Wk02My45IDY5LjRoLTQuMnY0LjJoNC4ydi00LjJaTTYzLjkgODYuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNNjMuOSA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNNjguMSA1Mi40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA2MC45SDY0VjY1aDQuMlY2MVpNNjguMSA2OS40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA3Ny45SDY0VjgyaDQuMlY3OFpNNjguMSA4Ni40SDY0djQuMmg0LjJ2LTQuMlpNNjguMSA5NC45SDY0Vjk5aDQuMlY5NVpNNjguMSAxMDMuNEg2NHY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTcyLjQgNTIuNEg2OHY0LjJoNC4zdi00LjJaTTcyLjQgNjkuNEg2OHY0LjJoNC4zdi00LjJaTTcyLjQgNzcuOUg2OFY4Mmg0LjNWNzhaTTcyLjQgODIuMUg2OHY0LjNoNC4zVjgyWk03Mi40IDg2LjRINjh2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Mi40IDkwLjZINjhWOTVoNC4zdi00LjNaTTcyLjQgOTkuMUg2OHY0LjNoNC4zVjk5Wk03Mi40IDEwMy40SDY4djQuMmg0LjN2LTQuMlpNNzYuNiAyMi43aC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDMxLjJoLTQuMnY0LjJoNC4ydi00LjJaTTc2LjYgMzkuN2gtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNzYuNiA0My45aC00LjJ2NC4yaDQuMlY0NFpNNzYuNiA0OC4xaC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTc2LjYgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDYwLjloLTQuMlY2NWg0LjJWNjFaTTc2LjYgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTc2LjYgNjkuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNNzYuNiA5NC45aC00LjJWOTloNC4yVjk1Wk03Ni42IDEwMy40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik03Ni42IDExNi4xaC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDEyNC42aC00LjJ2NC4yaDQuMnYtNC4yWk03Ni42IDEyOC44aC00LjJ2NC4zaDQuMnYtNC4zWk03Ni42IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk04MC45IDE4LjRoLTQuM3Y0LjNIODF2LTQuM1pNODAuOSAyN2gtNC4zdjQuMkg4MXYtNC4zWk04MC45IDMxLjJoLTQuM3Y0LjJIODF2LTQuMlpNODAuOSAzNS40aC00LjN2NC4zSDgxdi00LjNaTTgwLjkgMzkuN2gtNC4zdjQuMkg4MXYtNC4yWk04MC45IDQ4LjFoLTQuM3Y0LjNIODF2LTQuM1pNODAuOSA2MC45aC00LjNWNjVIODFWNjFaTTgwLjkgNjUuMWgtNC4zdjQuM0g4MVY2NVpNODAuOSA3My42aC00LjNWNzhIODF2LTQuM1pNODAuOSA4Mi4xaC00LjN2NC4zSDgxVjgyWk04MC45IDk0LjloLTQuM1Y5OUg4MVY5NVpNODAuOSA5OS4xaC00LjN2NC4zSDgxVjk5Wk04MC45IDEwMy40aC00LjN2NC4ySDgxdi00LjJaTTgwLjkgMTExLjloLTQuM3Y0LjJIODF2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODAuOSAxMTYuMWgtNC4zdjQuMkg4MXYtNC4yWk04MC45IDEyNC42aC00LjN2NC4ySDgxdi00LjJaTTgwLjkgMTM3LjNoLTQuM3Y0LjNIODF2LTQuM1pNODUuMSAxOC40SDgxdjQuM0g4NXYtNC4zWk04NS4xIDMxLjJIODF2NC4ySDg1di00LjJaTTg1LjEgMzUuNEg4MXY0LjNIODV2LTQuM1pNODUuMSA0My45SDgxdjQuMkg4NVY0NFpNODUuMSA1Ni42SDgxVjYxSDg1di00LjNaTTg1LjEgNzMuNkg4MVY3OEg4NXYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik04NS4xIDc3LjlIODFWODJIODVWNzhaTTg1LjEgODIuMUg4MXY0LjNIODVWODJaTTg1LjEgODYuNEg4MXY0LjJIODV2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODUuMSA5MC42SDgxVjk1SDg1di00LjNaTTg1LjEgOTkuMUg4MXY0LjNIODVWOTlaTTg1LjEgMTExLjlIODF2NC4ySDg1di00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTg1LjEgMTE2LjFIODF2NC4ySDg1di00LjJaTTg1LjEgMTMzSDgxdjQuM0g4NXYtNC4yWk04OS40IDIyLjdIODV2NC4yaDQuM3YtNC4yWk04OS40IDMxLjJIODV2NC4yaDQuM3YtNC4yWk04OS40IDM1LjRIODV2NC4zaDQuM3YtNC4zWk04OS40IDQ4LjFIODV2NC4zaDQuM3YtNC4zWk04OS40IDUyLjRIODV2NC4yaDQuM3YtNC4yWk04OS40IDU2LjZIODVWNjFoNC4zdi00LjNaTTg5LjQgNjUuMUg4NXY0LjNoNC4zVjY1Wk04OS40IDEwMy40SDg1djQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODkuNCAxMDcuNkg4NXY0LjNoNC4zdi00LjNaTTg5LjQgMTExLjlIODV2NC4yaDQuM3YtNC4yWk04OS40IDEyOC44SDg1djQuM2g0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNODkuNCAxMzNIODV2NC4zaDQuM3YtNC4yWk05My42IDE4LjRoLTQuMnY0LjNoNC4ydi00LjNaTTkzLjYgMjIuN2gtNC4ydjQuMmg0LjJ2LTQuMlpNOTMuNiAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNOTMuNiAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk05My42IDQzLjloLTQuMnY0LjJoNC4yVjQ0Wk05My42IDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNOTMuNiA2NS4xaC00LjJ2NC4zaDQuMlY2NVpNOTMuNiA2OS40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik05My42IDczLjZoLTQuMlY3OGg0LjJ2LTQuM1pNOTMuNiA4Mi4xaC00LjJ2NC4zaDQuMlY4MlpNOTMuNiA4Ni40aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik05My42IDkwLjZoLTQuMlY5NWg0LjJ2LTQuM1pNOTMuNiAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNOTMuNiAxMTYuMWgtNC4ydjQuMmg0LjJ2LTQuMlpNOTcuOSAxOC40aC00LjN2NC4zSDk4di00LjNaTTk3LjkgMjIuN2gtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDM1LjRoLTQuM3Y0LjNIOTh2LTQuM1pNOTcuOSA0OC4xaC00LjN2NC4zSDk4di00LjNaTTk3LjkgNTIuNGgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDYwLjloLTQuM1Y2NUg5OFY2MVpNOTcuOSA2NS4xaC00LjN2NC4zSDk4VjY1Wk05Ny45IDczLjZoLTQuM1Y3OEg5OHYtNC4zWk05Ny45IDgyLjFoLTQuM3Y0LjNIOThWODJaTTk3LjkgODYuNGgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDk0LjloLTQuM1Y5OUg5OFY5NVpNOTcuOSA5OS4xaC00LjN2NC4zSDk4Vjk5Wk05Ny45IDEwMy40aC00LjN2NC4ySDk4di00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTk3LjkgMTA3LjZoLTQuM3Y0LjNIOTh2LTQuM1pNOTcuOSAxMTYuMWgtNC4zdjQuMkg5OHYtNC4yWk05Ny45IDEyNC42aC00LjN2NC4ySDk4di00LjJaTTk3LjkgMTMzaC00LjN2NC4zSDk4di00LjJaTTEwMi4xIDIyLjdIOTh2NC4yaDQuMnYtNC4yWk0xMDIuMSA0My45SDk4djQuMmg0LjJWNDRaTTEwMi4xIDUyLjRIOTh2NC4yaDQuMnYtNC4yWk0xMDIuMSA2OS40SDk4djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAyLjEgNzMuNkg5OFY3OGg0LjJ2LTQuM1pNMTAyLjEgOTAuNkg5OFY5NWg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTAyLjEgOTQuOUg5OFY5OWg0LjJWOTVaTTEwMi4xIDEwMy40SDk4djQuMmg0LjJ2LTQuMlpNMTAyLjEgMTMzSDk4djQuM2g0LjJ2LTQuMlpNMTAyLjEgMTM3LjNIOTh2NC4zaDQuMnYtNC4zWk0xMDYuNCAxOC40SDEwMnY0LjNoNC4zdi00LjNaTTEwNi40IDIyLjdIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgNDguMUgxMDJ2NC4zaDQuM3YtNC4zWk0xMDYuNCA2MC45SDEwMlY2NWg0LjNWNjFaTTEwNi40IDczLjZIMTAyVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMDYuNCA3Ny45SDEwMlY4Mmg0LjNWNzhaTTEwNi40IDgyLjFIMTAydjQuM2g0LjNWODJaTTEwNi40IDg2LjRIMTAydjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTA2LjQgOTAuNkgxMDJWOTVoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEwNi40IDk0LjlIMTAyVjk5aDQuM1Y5NVpNMTA2LjQgMTAzLjRIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgMTE2LjFIMTAydjQuMmg0LjN2LTQuMlpNMTA2LjQgMTMzSDEwMnY0LjNoNC4zdi00LjJaTTEwNi40IDEzNy4zSDEwMnY0LjNoNC4zdi00LjNaTTExMC42IDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xMTAuNiAzOS43aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTAuNiA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTEwLjYgNDguMWgtNC4ydjQuM2g0LjJ2LTQuM1pNMTEwLjYgNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTEwLjYgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWk0xMTAuNiA5MC42aC00LjJWOTVoNC4ydi00LjNaTTExMC42IDk5LjFoLTQuMnY0LjNoNC4yVjk5Wk0xMTAuNiAxMDcuNmgtNC4ydjQuM2g0LjJ2LTQuM1pNMTEwLjYgMTE2LjFoLTQuMnY0LjJoNC4ydi00LjJaTTExMC42IDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMTAuNiAxMzNoLTQuMnY0LjNoNC4ydi00LjJaTTExMC42IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMTQuOSAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk0xMTQuOSA0OC4xaC00LjN2NC4zaDQuM3YtNC4zWk0xMTQuOSA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWk0xMTQuOSA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTE0LjkgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTQuOSA3Ny45aC00LjNWODJoNC4zVjc4Wk0xMTQuOSA4Mi4xaC00LjN2NC4zaDQuM1Y4MlpNMTE0LjkgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTE0LjkgOTkuMWgtNC4zdjQuM2g0LjNWOTlaTTExNC45IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTQuOSAxMTYuMWgtNC4zdjQuMmg0LjN2LTQuMlpNMTE0LjkgMTMzaC00LjN2NC4zaDQuM3YtNC4yWk0xMTQuOSAxMzcuM2gtNC4zdjQuM2g0LjN2LTQuM1pNMTE5LjEgMTguNEgxMTV2NC4zaDQuMnYtNC4zWk0xMTkuMSAzNS40SDExNXY0LjNoNC4ydi00LjNaTTExOS4xIDM5LjdIMTE1djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgNDMuOUgxMTV2NC4yaDQuMlY0NFpNMTE5LjEgNjUuMUgxMTV2NC4zaDQuMlY2NVpNMTE5LjEgNjkuNEgxMTV2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMTkuMSA3My42SDExNVY3OGg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgNzcuOUgxMTVWODJoNC4yVjc4Wk0xMTkuMSA4Mi4xSDExNXY0LjNoNC4yVjgyWk0xMTkuMSA4Ni40SDExNXY0LjJoNC4ydi00LjJaTTExOS4xIDk0LjlIMTE1Vjk5aDQuMlY5NVpNMTE5LjEgMTAzLjRIMTE1djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTE5LjEgMTA3LjZIMTE1djQuM2g0LjJ2LTQuM1pNMTE5LjEgMTE2LjFIMTE1djQuMmg0LjJ2LTQuMlpNMTE5LjEgMTI0LjZIMTE1djQuMmg0LjJ2LTQuMlpNMTE5LjEgMTMzSDExNXY0LjNoNC4ydi00LjJaTTExOS4xIDEzNy4zSDExNXY0LjNoNC4ydi00LjNaTTEyMy40IDMxLjJIMTE5djQuMmg0LjN2LTQuMlpNMTIzLjQgMzUuNEgxMTl2NC4zaDQuM3YtNC4zWk0xMjMuNCA1Ni42SDExOVY2MWg0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTIzLjQgNjAuOUgxMTlWNjVoNC4zVjYxWk0xMjMuNCA5MC42SDExOVY5NWg0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTIzLjQgOTQuOUgxMTlWOTloNC4zVjk1Wk0xMjMuNCA5OS4xSDExOXY0LjNoNC4zVjk5Wk0xMjMuNCAxMjAuM0gxMTl2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMjMuNCAxMjQuNkgxMTl2NC4yaDQuM3YtNC4yWk0xMjcuNiAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNMTI3LjYgMzEuMmgtNC4ydjQuMmg0LjJ2LTQuMlpNMTI3LjYgMzkuN2gtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgNDMuOWgtNC4ydjQuMmg0LjJWNDRaTTEyNy42IDQ4LjFoLTQuMnY0LjNoNC4ydi00LjNaTTEyNy42IDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNMTI3LjYgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTEyNy42IDY5LjRoLTQuMnY0LjJoNC4ydi00LjJaTTEyNy42IDc3LjloLTQuMlY4Mmg0LjJWNzhaTTEyNy42IDgyLjFoLTQuMnY0LjNoNC4yVjgyWk0xMjcuNiA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNMTI3LjYgMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEyNy42IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgMTE2LjFoLTQuMnY0LjJoNC4ydi00LjJaTTEyNy42IDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xMjcuNiAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTI3LjYgMTMzaC00LjJ2NC4zaDQuMnYtNC4yWk0xMzEuOSA1Mi40aC00LjN2NC4yaDQuM3YtNC4yWk0xMzEuOSA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTMxLjkgNzcuOWgtNC4zVjgyaDQuM1Y3OFpNMTMxLjkgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTMxLjkgOTQuOWgtNC4zVjk5aDQuM1Y5NVpNMTMxLjkgMTAzLjRoLTQuM3Y0LjJoNC4zdi00LjJaTTEzMS45IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWk0xMzEuOSAxMzNoLTQuM3Y0LjNoNC4zdi00LjJaTTEzMS45IDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWk0xMzYuMSA1Mi40aC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSA1Ni42aC00LjJWNjFoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEzNi4xIDYwLjloLTQuMlY2NWg0LjJWNjFaTTEzNi4xIDY1LjFoLTQuMnY0LjNoNC4yVjY1Wk0xMzYuMSA2OS40aC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSA5NC45aC00LjJWOTloNC4yVjk1Wk0xMzYuMSAxMDMuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTM2LjEgMTExLjloLTQuMnY0LjJoNC4ydi00LjJaTTEzNi4xIDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMzYuMSAxMjQuNmgtNC4ydjQuMmg0LjJ2LTQuMlpNMTM2LjEgMTI4LjhoLTQuMnY0LjNoNC4ydi00LjNaTTE0MC4zIDUyLjRoLTQuMnY0LjJoNC4ydi00LjJaTTE0MC4zIDU2LjZoLTQuMlY2MWg0LjJ2LTQuM1pNMTQwLjMgNjUuMWgtNC4ydjQuM2g0LjJWNjVaTTE0MC4zIDczLjZoLTQuMlY3OGg0LjJ2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQwLjMgNzcuOWgtNC4yVjgyaDQuMlY3OFpNMTQwLjMgODYuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQwLjMgOTQuOWgtNC4yVjk5aDQuMlY5NVpNMTQwLjMgMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaTTE0MC4zIDEyMC4zaC00LjJ2NC4zaDQuMnYtNC4zWk0xNDAuMyAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQ0LjYgNjAuOWgtNC4zVjY1aDQuM1Y2MVoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgODYuNGgtNC4zdjQuMmg0LjN2LTQuMlpNMTQ0LjYgMTAzLjRoLTQuM3Y0LjJoNC4zdi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0NC42IDEwNy42aC00LjN2NC4zaDQuM3YtNC4zWk0xNDQuNiAxMTEuOWgtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgMTE2LjFoLTQuM3Y0LjJoNC4zdi00LjJaTTE0NC42IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNDQuNiAxMjQuNmgtNC4zdjQuMmg0LjN2LTQuMlpNMTQ4LjggNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQ4LjggNjAuOWgtNC4yVjY1aDQuMlY2MVpNMTQ4LjggNzMuNmgtNC4yVjc4aDQuMnYtNC4zWk0xNDguOCA4Mi4xaC00LjJ2NC4zaDQuMlY4MlpNMTQ4LjggODYuNGgtNC4ydjQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ4LjggOTAuNmgtNC4yVjk1aDQuMnYtNC4zWk0xNDguOCA5OS4xaC00LjJ2NC4zaDQuMlY5OVpNMTQ4LjggMTAzLjRoLTQuMnY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0OC44IDEwNy42aC00LjJ2NC4zaDQuMnYtNC4zWk0xNDguOCAxMTEuOWgtNC4ydjQuMmg0LjJ2LTQuMlpNMTQ4LjggMTIwLjNoLTQuMnY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0OC44IDEyNC42aC00LjJ2NC4yaDQuMnYtNC4yWk0xNDguOCAxMjguOGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQ4LjggMTM3LjNoLTQuMnY0LjNoNC4ydi00LjNaTTE1MyA1Ni42aC00LjJWNjFoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE1MyA2MC45aC00LjJWNjVoNC4zVjYxWk0xNTMgNjUuMWgtNC4ydjQuM2g0LjNWNjVaTTE1MyA5NC45aC00LjJWOTloNC4zVjk1Wk0xNTMgOTkuMWgtNC4ydjQuM2g0LjNWOTlaTTE1MyAxMDMuNGgtNC4ydjQuMmg0LjN2LTQuMlpNMTUzIDEyMC4zaC00LjJ2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNTMgMTI0LjZoLTQuMnY0LjJoNC4zdi00LjJaTTE1MyAxMzcuM2gtNC4ydjQuM2g0LjN2LTQuM1pNMTU3LjMgNTIuNGgtNC4ydjQuMmg0LjJ2LTQuMlpNMTU3LjMgNTYuNmgtNC4yVjYxaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNTcuMyA2MC45aC00LjJWNjVoNC4yVjYxWk0xNTcuMyA2NS4xaC00LjJ2NC4zaDQuMlY2NVpNMTU3LjMgOTAuNmgtNC4yVjk1aDQuMnYtNC4zWk0xNTcuMyAxMTYuMWgtNC4ydjQuMmg0LjJ2LTQuMlpNMTU3LjMgMTI0LjZoLTQuMnY0LjJoNC4ydi00LjJaTTE1Ny4zIDEzM2gtNC4ydjQuM2g0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTYxLjYgNTYuNmgtNC4zVjYxaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiA2MC45aC00LjNWNjVoNC4zVjYxWk0xNjEuNiA2NS4xaC00LjN2NC4zaDQuM1Y2NVpNMTYxLjYgNzMuNmgtNC4zVjc4aDQuM3YtNC4zWk0xNjEuNiA5MC42aC00LjNWOTVoNC4zdi00LjNaTTE2MS42IDEwMy40aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiAxMDcuNmgtNC4zdjQuM2g0LjN2LTQuM1pNMTYxLjYgMTExLjloLTQuM3Y0LjJoNC4zdi00LjJaTTE2MS42IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiAxMjQuNmgtNC4zdjQuMmg0LjN2LTQuMlpNMTYxLjYgMTMzaC00LjN2NC4zaDQuM3YtNC4yWiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2IpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNMTYxLjYgMTguNEgzOC40djEyMy4yaDEyMy4yVjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJjIiB3aWR0aD0iMzEiIGhlaWdodD0iMzEiIHg9IjM4IiB5PSIxOCIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTQyLjcgMjIuN2gtNC4zdjQuMmg0LjN2LTQuMlpNNDIuNyAyN2gtNC4zdjQuMmg0LjN2LTQuM1pNNDIuNyAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDM1LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTQyLjcgMzkuN2gtNC4zdjQuMmg0LjN2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNDIuNyA0My45aC00LjN2NC4yaDQuM1Y0NFpNNDcgMTguNGgtNC4zdjQuM2g0LjJ2LTQuM1pNNDcgNDMuOWgtNC4zdjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTguNGgtNC4zdjQuM2g0LjN2LTQuM1pNNTEuMiA0My45aC00LjN2NC4yaDQuM1Y0NFoiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk01NS40IDQzLjloLTQuMnY0LjJoNC4yVjQ0Wk01OS43IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTU5LjcgNDMuOWgtNC4zdjQuMmg0LjNWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTYzLjkgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNNjMuOSA0My45aC00LjJ2NC4yaDQuMlY0NFpNNjguMSAxOC40SDY0djQuM2g0LjJ2LTQuM1pNNjguMSAyMi43SDY0djQuMmg0LjJ2LTQuMlpNNjguMSAyN0g2NHY0LjJoNC4ydi00LjNaTTY4LjEgMzEuMkg2NHY0LjJoNC4ydi00LjJaTTY4LjEgMzUuNEg2NHY0LjNoNC4ydi00LjNaTTY4LjEgMzkuN0g2NHY0LjJoNC4ydi00LjJaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTY4LjEgNDMuOUg2NHY0LjJoNC4yVjQ0WiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2MpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNjguMSAxOC40SDM4LjR2MjkuN2gyOS43VjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJkIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHg9IjQ2IiB5PSIyNiIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDI3aC00LjN2NC4yaDQuM3YtNC4zWk01MS4yIDMxLjJoLTQuM3Y0LjJoNC4zdi00LjJaTTUxLjIgMzUuNGgtNC4zdjQuM2g0LjN2LTQuM1oiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNTUuNCAyN2gtNC4ydjQuMmg0LjJ2LTQuM1pNNTUuNCAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDM1LjRoLTQuMnY0LjNoNC4ydi00LjNaTTU5LjcgMjdoLTQuM3Y0LjJoNC4zdi00LjNaTTU5LjcgMzEuMmgtNC4zdjQuMmg0LjN2LTQuMlpNNTkuNyAzNS40aC00LjN2NC4zaDQuM3YtNC4zWiIvPjwvbWFzaz48ZyBtYXNrPSJ1cmwoI2QpIj48cGF0aCBmaWxsPSIjMDAwIiBkPSJNNTkuNyAyN0g0Ni45djEyLjdoMTIuOFYyNi45WiIvPjwvZz48bWFzayBpZD0iZSIgd2lkdGg9IjMxIiBoZWlnaHQ9IjMxIiB4PSIxMzEiIHk9IjE4IiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBzdHlsZT0ibWFzay10eXBlOmx1bWluYW5jZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTEzNi4xIDE4LjRoLTQuMnY0LjNoNC4ydi00LjNaTTEzNi4xIDIyLjdoLTQuMnY0LjJoNC4ydi00LjJaTTEzNi4xIDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xMzYuMSAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk0xMzYuMSAzNS40aC00LjJ2NC4zaDQuMnYtNC4zWk0xMzYuMSAzOS43aC00LjJ2NC4yaDQuMnYtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xMzYuMSA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTQwLjMgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTQwLjMgNDMuOWgtNC4ydjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE0NC42IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE0NC42IDQzLjloLTQuM3Y0LjJoNC4zVjQ0Wk0xNDguOCAxOC40aC00LjJ2NC4zaDQuMnYtNC4zWk0xNDguOCA0My45aC00LjJ2NC4yaDQuMlY0NFpNMTUzIDE4LjRoLTQuMnY0LjNoNC4zdi00LjNaTTE1MyA0My45aC00LjJ2NC4yaDQuM1Y0NFpNMTU3LjMgMTguNGgtNC4ydjQuM2g0LjJ2LTQuM1pNMTU3LjMgNDMuOWgtNC4ydjQuMmg0LjJWNDRaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTE2MS42IDE4LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE2MS42IDIyLjdoLTQuM3Y0LjJoNC4zdi00LjJaTTE2MS42IDI3aC00LjN2NC4yaDQuM3YtNC4zWk0xNjEuNiAzMS4yaC00LjN2NC4yaDQuM3YtNC4yWk0xNjEuNiAzNS40aC00LjN2NC4zaDQuM3YtNC4zWk0xNjEuNiAzOS43aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik0xNjEuNiA0My45aC00LjN2NC4yaDQuM1Y0NFoiLz48L21hc2s+PGcgbWFzaz0idXJsKCNlKSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTE2MS42IDE4LjRoLTI5Ljd2MjkuN2gyOS43VjE4LjRaIi8+PC9nPjxtYXNrIGlkPSJmIiB3aWR0aD0iMTQiIGhlaWdodD0iMTQiIHg9IjE0MCIgeT0iMjYiIG1hc2tVbml0cz0idXNlclNwYWNlT25Vc2UiIHN0eWxlPSJtYXNrLXR5cGU6bHVtaW5hbmNlIj48cGF0aCBmaWxsPSIjZmZmIiBkPSJNMTQ0LjYgMjdoLTQuM3Y0LjJoNC4zdi00LjNaTTE0NC42IDMxLjJoLTQuM3Y0LjJoNC4zdi00LjJaTTE0NC42IDM1LjRoLTQuM3Y0LjNoNC4zdi00LjNaTTE0OC44IDI3aC00LjJ2NC4yaDQuMnYtNC4zWk0xNDguOCAzMS4yaC00LjJ2NC4yaDQuMnYtNC4yWk0xNDguOCAzNS40aC00LjJ2NC4zaDQuMnYtNC4zWk0xNTMgMjdoLTQuMnY0LjJoNC4zdi00LjNaTTE1MyAzMS4yaC00LjJ2NC4yaDQuM3YtNC4yWk0xNTMgMzUuNGgtNC4ydjQuM2g0LjN2LTQuM1oiLz48L21hc2s+PGcgbWFzaz0idXJsKCNmKSI+PHBhdGggZmlsbD0iIzAwMCIgZD0iTTE1MyAyN2gtMTIuN3YxMi43aDEyLjhWMjYuOVoiLz48L2c+PG1hc2sgaWQ9ImciIHdpZHRoPSIzMSIgaGVpZ2h0PSIzMSIgeD0iMzgiIHk9IjExMSIgbWFza1VuaXRzPSJ1c2VyU3BhY2VPblVzZSIgc3R5bGU9Im1hc2stdHlwZTpsdW1pbmFuY2UiPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDExNi4xaC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDEyMC4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDEyNC42aC00LjN2NC4yaDQuM3YtNC4yWk00Mi43IDEyOC44aC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik00Mi43IDEzM2gtNC4zdjQuM2g0LjN2LTQuMlpNNDIuNyAxMzcuM2gtNC4zdjQuM2g0LjN2LTQuM1pNNDcgMTExLjloLTQuM3Y0LjJoNC4ydi00LjJaTTQ3IDEzNy4zaC00LjN2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01MS4yIDExMS45aC00LjN2NC4yaDQuM3YtNC4yWk01MS4yIDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik01NS40IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWk01NS40IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk01OS43IDExMS45aC00LjN2NC4yaDQuM3YtNC4yWk01OS43IDEzNy4zaC00LjN2NC4zaDQuM3YtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02My45IDExMS45aC00LjJ2NC4yaDQuMnYtNC4yWk02My45IDEzNy4zaC00LjJ2NC4zaDQuMnYtNC4zWk02OC4xIDExMS45SDY0djQuMmg0LjJ2LTQuMloiLz48cGF0aCBmaWxsPSIjZmZmIiBkPSJNNjguMSAxMTYuMUg2NHY0LjJoNC4ydi00LjJaTTY4LjEgMTIwLjNINjR2NC4zaDQuMnYtNC4zWiIvPjxwYXRoIGZpbGw9IiNmZmYiIGQ9Ik02OC4xIDEyNC42SDY0djQuMmg0LjJ2LTQuMlpNNjguMSAxMjguOEg2NHY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTY4LjEgMTMzSDY0djQuM2g0LjJ2LTQuMlpNNjguMSAxMzcuM0g2NHY0LjNoNC4ydi00LjNaIi8+PC9tYXNrPjxnIG1hc2s9InVybCgjZykiPjxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik02OC4xIDExMS45SDM4LjR2MjkuN2gyOS43di0yOS43WiIvPjwvZz48bWFzayBpZD0iaCIgd2lkdGg9IjE0IiBoZWlnaHQ9IjE0IiB4PSI0NiIgeT0iMTIwIiBtYXNrVW5pdHM9InVzZXJTcGFjZU9uVXNlIiBzdHlsZT0ibWFzay10eXBlOmx1bWluYW5jZSI+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTIwLjNoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTUxLjIgMTI0LjZoLTQuM3Y0LjJoNC4zdi00LjJaTTUxLjIgMTI4LjhoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU1LjQgMTIwLjNoLTQuMnY0LjNoNC4ydi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU1LjQgMTI0LjZoLTQuMnY0LjJoNC4ydi00LjJaTTU1LjQgMTI4LjhoLTQuMnY0LjNoNC4ydi00LjNaTTU5LjcgMTIwLjNoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PHBhdGggZmlsbD0iI2ZmZiIgZD0iTTU5LjcgMTI0LjZoLTQuM3Y0LjJoNC4zdi00LjJaTTU5LjcgMTI4LjhoLTQuM3Y0LjNoNC4zdi00LjNaIi8+PC9tYXNrPjxnIG1hc2s9InVybCgjaCkiPjxwYXRoIGZpbGw9IiMwMDAiIGQ9Ik01OS43IDEyMC4zSDQ2Ljl2MTIuOGgxMi44di0xMi44WiIvPjwvZz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJhIj48cmVjdCB3aWR0aD0iMTM3IiBoZWlnaHQ9IjEzNyIgeD0iMzEuNSIgeT0iMTEuNSIgZmlsbD0iI2ZmZiIgcng9IjguMiIvPjwvY2xpcFBhdGg+PC9kZWZzPjwvc3ZnPg==", + "description": "Enables users to open the ThingsBoard mobile app by scanning the QR code from dashboard.", + "descriptor": { + "type": "static", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n}\n", + "settingsSchema": "", + "dataKeySettingsSchema": "", + "settingsDirective": "tb-mobile-app-qr-code-widget-settings", + "defaultConfig": "{\n \"showTitle\": true,\n \"backgroundColor\": \"rgb(255, 255, 255)\",\n \"color\": \"rgba(0, 0, 0, 0.87)\",\n \"padding\": \"8px\",\n \"settings\": {\n \"useSystemSettings\": true,\n \"androidConfig\": {\n \"enabled\": true\n },\n \"iosConfig\": {\n \"enabled\": true\n },\n \"qrCodeConfig\": {\n \"badgeEnabled\": true,\n \"badgeStyle\": \"ORIGINAL\",\n \"badgePosition\": \"RIGHT\",\n \"qrCodeLabelEnabled\": true,\n \"qrCodeLabel\": \"Scan to connect or download mobile app\"\n }\n },\n \"title\": \"Mobile app QR code\",\n \"dropShadow\": true,\n \"enableFullscreen\": false,\n \"widgetStyle\": {},\n \"widgetCss\": \"\",\n \"pageSize\": 1024,\n \"noDataDisplayMessage\": \"\",\n \"showTitleIcon\": false,\n \"titleTooltip\": \"\",\n \"titleStyle\": {\n \"fontSize\": \"16px\",\n \"fontWeight\": 400\n }\n}\n" + }, + "tags": [ + "ios", + "android", + "qr code", + "mobile application" + ] +} \ No newline at end of file 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,iVBORw0KGgoAAAANSUhEUgAAAagAAAGoCAYAAAATsnHAAAAmgUlEQVR4Ae3de2xc5bnv8cd2jO04tmM7V8dJHEfQxpB9ykXFFNhtyWkq0cKptPNHuIgKyt7/UIlIRagCCaFKjRClbegp0tERUG1EaCqlPToN7D8S0tNuOBC0S6E7wSmhnkBw7Ni5OI7jeBw7yZ7HzkrGk5m13nWZmXfNfD/SlMRek4uh6+fnfZ/1vBUCP+ZkeVWmXhUXfywXf14pAMrN+YzXhYv/nMrygoEKQTYaMFelvarlchgBQFhnU69zqVfy4o/1dV4wCwE1Q4NnrsyEUZ1croYAoFA0pCZTrzMXf1z2lVY5B1StzIRSjcwEEwDYRENqQmYCKyllqNwCygmlemG5DkB8aDWlYXVayiisyiGgCCUApcQJq1MyU2WVrFINKA2ieXJ5CQ8ASpEG1KjMVFYlp9QCSoOpMfVqEKolAOXDqapOSgk1V5RKQGnX3XyZWcbLm/vuu6/xrrvuau/s7Gxvbm5ub2hoaK+urm6sra1tr6qqaqysrNRwlNTH2gVAWTl//vypc+fOnXL+OTU1dWp8fLxvdHS0b3h4uC+RSPRt3LixR/JvTEokqOIeUBpMCyQPy3gaRo8++mj3kiVLupqamrrq6uq6CB4AYU1MTPSkXn0jIyM9Bw4c2POrX/2qZ+vWrackerEPqrgGVOQVkwbSE088sb61tbWrpaVlPWEEoFA0tMbGxnp6e3t3/vGPf+x5/PHH+yQ6sQ2quAVUpHtMb775ZvcXv/jF7tRyXffcuXO7BQAscObMmT39/f0733///T0RLgtqSGlDRWwmVsQpoLQjr1lCTnl49tln2++9994NixYt2kCVBMB2k5OTfSdOnNj5yiuvvBxBZaVV1IjEpOsvDgEVep9Jl++efvrpDW1tbeuplADElVZWiURi+9q1a7dLOLFY9rM9oHQ5r0kCLuc51dLSpUsfcjrsACDutKoaHR3d89JLL20JUVXpUp82Z4yIpWwNqFBVk+4t3XLLLZuolgCUutTy3/aQQaWjk46LhdWUjQEVuGoimACUq5BBpdXUsFi2N2VTQGkgaet4g/ikS3mPPPLIcwQTgHIXMqi0y0/3pqzo9LMloHRJb7H47NDT5oef//znmxYuXPiQAAAuCRFUutQ3KBYs+dkQUNo+3io+l/R6e3sf6ujo2ETzAwBkp80UH3/88ZYAXX9aQem+1BkpomIPVNUlvYV+/hzbtm3rGhsb29bZ2fkU4QQAuemzntddd91zZ8+efVu3Qny8Ve/Jem9ukiIqZgXVIj73m/r6+jYtW7ZskwAAfDt8+PCW9vb2LeKP7kudkCIoRgWlv+cS8RFOmvzJZPLfCCcACE7voQGqKb1X+1rpikqhKyhtgtC/6FWmb2CvCQCipUeCfPrpp1tWr179so+36eGIR6WAzROFTESnU88onLRDb2ho6Cn2mgAgWnpP1XvrkSNHntJ7reHb9N7tu9s6jCopDF9t5Fp+bt68+V8bGxvXCwAgL+bNm3f9XSkNDQ07d+3aZXImlRY12nk9LgV4VqoQS3y+wkmnQXz961//31RNAFAY2o7+u9/97l98HO1RkGel8h1QvsLpo48+2tDV1fWcAAAKLpFI/MjHvlTeQyqfAaWloPGeEy3kAFB8PlvRtXFCQyovy335bJLQaeSEEwDEiN6L9Z5seLne4xdKnuSrgjJ+CFc79ZilBwB28VlJ5eVh3nwElI4vMhqPMTw8/Nz8+fM3CADAOjpwtrW19THDy3UKeqSHH0a9xKfth0bhpCUk4QQA9mppadmgz0oZXq7FyVyJUJQBpZ16rSYXsucEAPGwePHih3zsSWkGRPYgb1QB5XTsef56hBMAxIuPxgnjLDARVUBpaeeZmjpXj3ACgPjRe/fevXtNtmU0C+ZLBKIIqHli0LGn5zjp7CcBAMSSni2l034MLtVMCD0NKOwsPmc6uWs3oM7We/DBB/+1qqqK8UUAEGMdHR3r582bt8Ngdl+NzJzIG/gh3rABtSj1qna7QCflPvPMM/9HT3YUAECsVVRU1Nx0003dBw8e3JFa8ptwu1RmHuQdk4DCPAdl9LwTD+ICQOkZHBx8ecmSJT8yuHQ49TKZlH6FoHtQurTnue+kTRGEEwCUHm0/13u8waVayARqPQ+6xKejjGrcLtB9pzvvvPN/ajkoAICS09TUdL3BflTgpb4gS3zatef5QK6ee8++EwCUtomJiZ7a2to7DS7V4+LPiA9+l/j0es99J32gi3ACgNJXU1PTdejQIZPnW5vFZ+b4DShtE3ddS9SlPR7GBYDysXz58k36rKvHZUa9C+n8LPHpL77M6yKW9gCg/Jw5c2ZPfX39Ro/L9JmoATE8hddPBeU5ukKPbCecAKD8zJ07t9ugq08zx3gMkmlAafVU73aBLu1dffXVLO0BQJnq6OjYpMMZPC7TLDFqOzdd4lsgHgF1/Pjx5/TsEAEAlC3DB3iTeqnHNUYVlFH1RDgBAPQBXs0Ej8tqxaCKMqmgqJ4AAMYMGyb0wd1jbhd4VVBUTwAAX7RhwuBYDs0W1wzyqqCongAAvhlWUSdTr5Fcn/SqoDzn7RFOAIBMhlWUdvzlzCG3Cspz5h7VEwAgF8MqKudxHG4VlOtICqonAIAbraIMOvrqcn0iVwWlo9GXiguqJwCAl88//3zLihUrtnhcps9EJTM/mKuC8noSWBoaGrzWFgEAZW7ZsmUPGUyXyFpF5Qoo1+YIZu4BAExUVlY2Pv30016rbfOyvjfLxzyf8O3o6GBpDwBgpK2tbb3HJZpFtdk+mClrkjl0w0s3vgQAAAOaGZs3b/bdLJEtoFyX9x544AGvceoAAMxy//33+17mywwoz+W9lpYWr1INAIBZlixZ4hVQVyzzZQbUXHGhR/rSHAEA8Euzw2CyxKxlvsyAcl3eu/HGG9l7AgAEcs0113hlSM4KSpf2rnJ7p0EnBgAAWbW2tnoFlGbQpVyqzPhETnTvAQDC0AwxeGj30lZTZbYPZvO1r32tSwAACOGHP/yh10pctfODymwfzGb16tUs7wEAQlm4cKFXsXNFBaX/dF3iq6+vp4ICAIRi8KiS9kNMZ5MTUK7hpGuGNTU1BBQAIBRtNzfYh5rOJKOAevDBBwknAEAkvv/975t0810KqFq3Kw161wEAMLJs2TKvomdWQFW5XdnU1EQFBQCIhEGmTDftGS3xpfafGG8EAIhEXV2dV0BNz4StFI/hsIoGCQBAVAwaJaazyTOgdECsAAAQobvuustrZc47oDo7O1neAwBEauXKleEDqrm5mYACAERqwYIF4QOqoaGBgAIARMogW6YDqtLtirq6OgIKABCp6upqr2kSFZ4BNWfOHK9fBAAAX2pra72Kn0oNpwq3K6qqqggoAECkDLLFe4mvsrKSgAIARMokWzwDigoKAFAElVRQAICC02kSHpdUVgoAABYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWIqAAAFYioAAAViKgAABWmiMAIpfYOyIf/GFIBhKnZXxsSpoX1Urz4lpZdV2T3LBukQDwVpF6rXS74MKFC58KACMaTL99/oCcHJrIeY2G1R33rCCoUPYqKio6XD8vBBQQid2vHZI/bDtkfP0dG1fIuntXCFCuvAKKPSggAn7DSen1+j4A2RFQQEjDg0nf4eTQ9x1MLQsCuBIBBYS0+9fhqqCw7wdKFQEFhJAcm5ru1gvj4L6R6V8HwGwEFBBCf2JMopDYxzIfkImAAkIYHkpKFE4OTgiA2QgowALjLPEBVyCggBDq6qMZxrK0s14AzEZAASF0rm2SKOh0CQCzEVBACLWpCkrn64Wh4bR0FRUUkImAAkJad0+4cUV33MO4IyAbAgoIaVVqme/6O4INfr1h3WKGxgI5EFBABL79z52+l+n0+m89vEoAZMc0cyBCb7yYkHd+3+953a13t00v7dXWcyQbyhfHbQAFpsNjdb6ejjBKPxdKw0iX87q6W0M3VgClgIACikhn7CXHzqXCqYpqCcjgFVD8PwbIIw0lggkIhiYJAICV+NYOyIN9u0QaFqReC2d+PpXaiho/JXL6qEhnt0hdowDwQEABeTDQk3rl+JwGFABvLPEBAKxEQAEArERAAQCsREABAKxEQAEArEQXH5AHtbSRA6Ex6ggAUBReo45Y4gMAWImAAgBYiT0oxJZOCu9PjMnAwbHpHyM4PTyxc20Tg21hFf5rRCztfu2QvLOjn2CKkIbTV+5qk3X3rhDABjRJIFY0kF798f7pwwCRH1pNPbx5LdUU8o4mCZQUrZwIp/zSJdPtWz4RoNgIKMRGYu/I9LIe8m//e8fl4F6+EUBxEVCIjb/sHhQUzu5fHxKgmAgoxMaR1NITCmeArzeKjF1QxIbJDVM39pd21k//2GuJSq+bv6hWBhKn5eTQhGA2OiRRbAQUSsa6e1bIHfdcbpEeHkzKi0/uvSJ8NMTuf3KNrLqu6dLHdPnwjRcPclMGLMISH0rCDesWzwon1by4Vh7+8dorrv3Ww52zwsl5/1fubhMA9iCgUBJuWLco68c1pK4Mo+zX3kpAAVYhoFASmlN7SbmkP3Bqeh2A4iOgUBJ69hzP+bmBg6cv/Xh4KJlzn0n3rADYg4BCSXjn9/1ZO/F08kTmx3NNSdAmCQD2YE0DJUEroxef2DvdKKGz5MZTVdL+VFWVbfKETklwrtU9Kq2c/vBrRigBtmFYLGLjybvfFhTWj39/mwD5wrBYAEAsEVAAACsRUAAAKxFQAAArEVAAACsRUAAAKxFQAAArEVCIjfmLagSF4za3ECgEAgqxoUdioHBWrW0SoJgIKMTGDXcsYuJ4Aa3LOF8LKDQCCrGhc/PWbeSmWQgaTiypotgIKMTKV/5HG9/Z55l+fe/gawwLMCwWsTSQGJM3XkrIwb1MII+K7jlpOGWeQAzki9ewWAIKsaaHDw4cHBMEp/t6zanlPPb3UGgEFADAShy3AQCIJWp6wND4qdyfq65J/Z8pQNPb1ITI5ETuz9c1ClC2CCjA0Ec7RYYPZ//ctd8QaesS34Z6U7/uruyf03C67UEByhZLfAAAKxFQAAArEVAAACsRUAAAKxFQAAArEVAAACsRUAAAKxFQAAArEVAAACsRUAAAKxFQAAArEVAAACsRUAAAKzHNHCVBj6043CNy+qj7sRhhjB6TyLkd0aHHcPx5u4T6tRetDjZlHbABAYXY608F08f/PhNSxRL03Ca39+nfJ9fxHqaOJkR63xP54j+KLFwtQKywxIdY690zc55SMcNJNSyUQPR9QQ469COZqig/fD0V5PsFiBUCCrE1mlrOS7wnRadLaGFCZsWXpCA+/lP+lj+BfCCgEFu5TqItJF2iW32zhLLy+sIc7a5Vpg1fM8AUAYVY0kpAK6hi0lC56Z9EakOGi1Zf+usUIqSG+4q/HAqYIqAQS8UMJw0UXdbrvjd8ODlqL4Zd2xrJO5b5EBd08SGW3KoAbTzI176OhlNLe34aGzSkrl0v8oWvzgRwmCBx62qkgkJcEFAoOU6FE1f6529OhWCzBKfdjQQR4o4lPgCAlQgoAICVCCgAgJUIKACAlQgoAICV6OJDLOlDrbk69eoapOSc6JtpO9e/t7a5e9Ep5lNns38uSIu8398fiEJF6rXS7YILFy58KgCKQkPhr6/PfjB5adfMdPJ8D5l16JEf6VPVnYeKCzH5AqWtoqKiw+3zLPEBFvto55VTMwZ6RD77QApCB8xmHvmh09GZ6YdCIKAAS7mdB3XoQymIoUT2jw/3MTIJ+UdAAZaadJkEUagpEUlCCEVEQAEArERAAQCsRJs5rJAcm5LhwQkZT/2z0FoW18r8RQVqiTM0PDQhA58k5eRw7msSeyXv3H7/zw+INLTMkaWd9QLkA23mKLq/7B6SN15MTIdUsXzl7jb51sOdUmz////2yzs7DsvJofiMIm9eVCt33LNCbli3SAA/vNrMCSgU1fBgUp775z+LDfQmuy71KgYN51d/vF8O7huRuFq6ql7uf7LLumoU9uI5KFht968PiS3e+X2/FMuLT+yNdTipgYNjqZDtESAqBBSKyqalrGItMf5l9+D0zb0U6N/Dpm86EG8EFFBkpXZDL2YlitJCQAFFNJAYi1VDhAmtREulIkRx0WaOopq/uEbmD5Xvprqftvra+jmpV5UUS3LsnPEyqAavNk0AYRBQKKoNj14j5UBHE/3tTyJHE7PHFB3pT3q+99a726Y7DDWgik27Lj/4w5DnsqQOkz2+b/bHdAr76puZgg5zBBRQAO++Fmyunba931Gk1vdsmhfPPPNUO3eOvPFSwtd7dQr76aMi3fcKYIQ9KCDPhnqDhZNWTDaFU7ob/vuiQBWdHh2iXw/ABBUUiqq/x/zYhtXdEojeEDPPVIr693Bj+ntnqrNgSS8XZz8sSGu+fj30xF/ACwGFotKAynXmUaYwATWw3+zafAQUey6z8fWAKZb4gDzTaoGb8gz9OrR1CWCEgALybE6NyE3/JNLcLmVN//76dQBMscQHFEBt48zNWVvM09vM//Ntkb8ZLj9meutls+talotc+43LP//rDpFThvtitz8kgXzhqyL/cOvsj9VSRcInAgooIK2m5qQ9l1wV4hnl5KjZdZlNKHqUvOl7g5pzFYGE8FjiAwBYiQoKsXGiTwIZz3O14Ie2WE+mLfGdDtiCbjut2jL/fbWU+R4c/COgEBvv/1ZiS4Ppw9evfGD3SIkO/k68J3ImYxqSsw9HRyNMscQH5JlWE9nCqdzo3//Pv53dJAK4IaCAPBvuI5wc+nXo59BdGCKggDwbJ5xmmaSCgiECCkVVDq3INAfM1rBQACMEFIqq2qKzCufk6c+iExTKfYqEQ8OJQbEwRRcfisqm76bz+Wf50rdFet+b2Y9KX+KqPiGBmVafmcGr76vNc+t9dZYHdZd1iaz4kgDGCCgUlX43raev2qBtjeSNhsQX/vHKj7+/W2TvBxLI7Q9KINetl7y75qsiN6wTIBSW+FBUeuO2ZfmLvSLALgQUim71zVJ0egQEs+MAu7DEh6JzmgiGA44yikIhQlIfUD3cM/tB1aMhjj/v3WN2XeYZTIU6xbi3/vLPtVLmXCz4RUAhL/QmrM0Apjck3Z/Z85oUhd6E8109aSh8/O9XTlE4GmLUkY4TMqHhnxlQ+T7F+GhCpGp89scOpP7+167P714fSgsBBV+cG6xbS7ZWBft2zVyrN37tYPPqkNPPa0jpTbyQ9PftzHP1pNWKLY0gxfbxn1KVVGf+WvpRWtiDgjFdUvp//2vm9eGO3DPV/pZWKTjz10ysuD74d+xBaDgV4oTXoRDLeKVG/7tg1BFMEVAwojfZ9CUlXcLJtgei1ULm3Dm9KZnueWg1U4iQco4f5zv5wmPUEUyxxAcjo1nOLTLdx/BLQ0r3KbTyinqOnQaSBmAhHxhtZLTPLLTzwxQVFALLtsSXa3SR3w493bu67cHUpvo3onlOygkmfbi10NMM9M9fyKVLm+nXgbFPMEUFBSN1Psbq1DaklvkyRumMBjw5VrvP9JW8eEKr/jp6Qq7XmUL659CwnLdgpoIp9k1Rq0L9ugwlZv/ZxyZT/7NfSo7+95L+Ndefa3PEQubwwQcCCoHp8pvebDP3cfTGNJBx0+3fP/Pdc9A9n9qMZ3niyAnbdBW7Rf6jwJ2LhaCBzKgjhMUSH4zkqkCyVUbLsgSJBlmv4XM7AKAIKBjRJZps1c+JLHtLGmbZrj30wUwlBQAmWOKDMX1uKLPZIVcnn44OyvbQrT6oqUcxlONehIb5QM9Mm7WzD3XwgARm+gxX5jcLX/iq9x5eWJ+lvhk5P3z5586eVNyXaVFYBBSMNS/LElAXGxcyJ0XoQ7daLWUuAeqN8cPXZz6v3XTlMptNnxnLNpooOSKBBW38KMQZXKePpf7bqL78c/1GRv970H1LOhphiiU+GMv13W+uSQk64ihXU4Qu9+nsPR2JpO/X6iLqZ55soX+vBPtv0xLvFXcoMOKFCgrGnGWazBvMoQ9nwiuzGtLOO32O6a+vZ//1tJrSJa+BntnvMZndFyeMOppNq2qehYIJKij4km0StQZNriGvesTCf/u2eXu5Pu/04euCGEiOnRMgnwgo+JKtUlI6wTxXh56G1C33mu83aUiV0nKftt0Hef5reCgpB/eG2KTKo+HBZCqgpiSIRTysC0MEFHzTZbtstEMvV7Ckjy7yCir9fCk1T2g46VEiQfz2+U/k5JBd01U1nF58cq8EoR2EnFwMUxWp10q3Cy5cuPCpABl0kGu2zW69+Wj7s1fATB+a1zfT3ZUeanoz1/eX0h6UQyvDwd7ZLd6f/OegvPX6J57vXXNzqyztrJdi07Ds2XPcs3q6/dvXyNX/sOjSz/Xf6+LVhBNmq6io6HD9vBBQCEBvtu++lv15GtOQckxNzD4IsZyOwBhIjMkvN30gpebhzWtl1XVNArjxCiiW+BCI06GXjYaXtpCbHkw3PWC2ceZVbuczNS+ukdr60mumXbqq+NUe4o+AQmC62Z3roUutiPSYc33OqVSfb4qChtOam1uklNywbnFJhi4Kj4BCKF4n4OozTm//iqBys+6eFSVzQ9e/h/59gCiwB4VI6ISAbEfAZ9JxSTqHT09VLcVGiKC08WDr5vhP0r3/iTWyprtVABM0SaBgdM9JH9j1M4hUD/FzmikaFs0MmS23fSiH075tW1u5iVVrm+Rb3+u0otMQ8UFAoaC0QUKX84LOW9PqSkcdlbOD+0YksXdEjn4+Mf0AdC7psxGdE4ezyTzddqg39zcRWtWmV7ZujS7676qhpUq6UhUTHXsIgoBCUeiNTZf9guw7feNRgcx87XT/Lpf0r5N+vbUpJRsNp/SjOf68PfcxKZl7iruel5z0wetymUaP/PAKKFptkBfO8eZhggpAeSOgkFdOUOmSny4t6Xfu2Y6Jd3BWEAAHAYWC0GUmZx9E9z80pMYzhsK2tHMMQyG4NaFU11x57VT8ejZQIggoFJze9KYDS+CmrjF3QGQGudteUOYRKdoEcTSR/drM1n/9ebaGl1Ib6As78aAuYDFtu8+koXVdxpgpDaxsZ3VpiLRkhNnK67OHy4rrrww+ncKereLSa4F8o4sPsJw2muhZW7ocqhWNhlauh5zTr9VzqFZ8KXvAaFu6Plg9emzm8xpinTdn/zUzr9Xfn6VYRIE2cwCAlZhmDgCIJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUCCgBgJQIKAGAlAgoAYCUNqPNuF0xOTvYJAAAROn/+/CmvSzwDCgCAqJ07d84ooNyv8E45AAB8Ma2gzrldYZByAAD4YlpBuS7xJZNJ9qAAAJGampoKH1CTk5NUUACASI2Pj3sVPxc0oKbcrhgdHaWCAgBEyiBbpjwDanh4mIACAETq2LFj4QMqkUgQUACASH322WfhA2rHjh0EFAAgUhs3buzxuGSq4uIPlovL2KOzZ8++XV1d3S4AAIQ0MTHRU1tbe6fHZZ85oeRaRY2Pj3slHQAARlIB5bUyd1b/xwmoSbcrR0ZGCCgAQCQMMmW6aHIC6qzblUeOHCGgAACR2L9//x6PSyb0f4wC6vnnn/f6xQAAMPLKK694FT3TmeQ0SWhQLXe9mkYJAEBIhg0Sn0vaNHMdd+TaKHHixImdAgBACGNjYybV0/QIvvTW8nG3dxw/fpx9KABAKAcOHPAqdi5tOVVm+2A2mzdvpoICAITy1ltveRU7l4qlirQPeu5DpUqzbXPnzu0WAAB8OnPmzJ76+vqNHpcdlow2c6Vrfq5VVGqZj24+AEAg/f39Jst7l/ohMscbTbi9M7V2SEABAAJ5//33vTIkmf6TioxP1qZei8UF7eYAAL8mJyf7rrrqqts8LhuUtJDKrKD0E64n7B45cmS7AADgg8GjSrq0N6uCyjbBfExcbNu2jYACAPjyyiuvvOxxSTLzAxVZLvJc5qObDwBgyrB7b9bynspWQXku8xl0YgAAMC2RSHitvF2xvKeqclw8J/WqyfE5XUvs/c53vnNfRUVFzmsAANDmiLa2tsc8LjsjWaYZVbpcnNPWrVtPHT582Gs9EQBQ5kZHR00eTxrN9sFcAaWlluszUTRLAAC8vPTSS1s8LjkrOYZEVLi8qTH1anb5PM0SAICcUttB21tbW72W946nXqezfaLS5U36BtdmiXfffdcrGQEAZcqgetLmiNO5PulWQan5qVeT2wVUUQCATIbVk4bT8VyfdKug1CmPz1NFAQCuYFA9qRG3T3pVUKo19ZrndgFVFADAEUX1pLwqKDXidcELL7zg9QcBAJSJKKonZVJBqSXi8uCuOnLkyFOLFy9+SAAAZSuq6kmZVFDqmNcFP/jBD7acP3/ec88KAFCadGpEVNWTMq2glOdeVG9v70OdnZ1PCQCg7Ozbt++xtWvXeg1xMKqelJ+A0vl8S8Wj6qJhAgDKj+GBhOqwpB3r7sZ0iU8u/oKjXhft2LHjRwIAKCtbtmzxOk5DnRTDcFJ+KiilgaZV1By3iw4dOrRp+fLlmwQAUPI+//zzLStWrDCZGjEgHhOK0vkNKDU39VrodVEymfy3mpqaLgEAlCwfS3s5Z+7l4meJz6FHcUx4XfSLX/ziX+jqA4DSpfd4w6W90+IznFSQCkoZNUzQ1QcApevvf//7j66++mqvswF1SU+X9oz3nhxBKii5+Bt59rGvXr365cHBQQ42BIASo/d2g3BSupLmO5xU0IByflPPpT59gHdiYqJHAAAlQfed9N5ucKkefmv0UG42VRKO/ub68G7OpcK9e/dONDY2/umWW27ZUFFRUSMAgNjScNJ9p5/97GdHPS7Vpb0h8dG1lynoHlQ6z5N31Ztvvtm9bt26bQIAiK3f/OY3d27cuNFkVcx3116mKAJKaUA1el300Ucfbejq6npOAACxY9gUoXQLaFhCCrMHlU7XGD03wa699trt+kCXAABiRe/dhuFk1ERnIqqA0jXGQTFYa9SnjQkpAIgPw0kRyjgLTEQVUEpT02hCrf5FaT8HAPv5CCelGRCopTybqPag0s1PvZpMLhweHn5u/vz5GwQAYB3DwwcdOgg2kqU9R5QVlEP/kEYjjpqbmx9juQ8A7KOrXD7CSe/5kYaTykdAKe3e8HyIV7EnBQB20XvykiVLTI9O0vmsoTv2sslXQCl9QOusyYWEFADYweeek97jjXoPgshnQGkXhz5pbLRhpl+QRCLBYYcAUCR6ZLuPcNJ7u97jI+nYyyafAaX0LzAohiGlw2X1KWUdpSEAgILQYzN27ty5ce3atdsN3+Lr3h5U2Fl8JjRdx2XmoEPPQNy+ffvRhoaGnV/+8pfXV1VVeU6nAAAEp8O8dbbed7/7XdOh3gUJJ1WIgFK+QmrXrl2nUst922+99daaefPmXS8AgMhpp94jjzzymMHgV0fBwknl4zkoN3rQoR4Xf5XpG/TQw46Ojk2VlZVUUwAQAV3SSxUBpqOLHNoQYdxXEIV870FlctJ33PQNui/105/+lH0pAIiALunpPdVnOOnRSgWrnByFrqDSGU1AT3fo0KFNy5cv3yQAAN98tpA7IplMHkShK6h0+hf29eSxfmF/8pOf3EY1BQDmzpw5s0c7pAOEk04GKko4qWJWUA5tnGgVn2GpZ0ulStRN1dXV7QIAuELAvabpt8rMA7hnpIiKWUE59AswID7XNvVsKW2N1GGGAgCYRTv0HnjggdsChJPei/WeXNRwUjZUUA4NS52C7rtb79lnn23/3ve+t6mlpYXJ6ADKmi7nvfDCC489/vjjQbZCnKGveZsO4YdNAeWYJzMNFL6rO4IKQLnSYHr77be3fPOb39wj/mkg6X7TqFjExoBS+rzUgtSrRgIgqACUi5DBpLSFPNKDBqNia0A59PDDBgm4V+YEVUNDQzfNFABKhTY/HD58+OVt27ZtD7iUN/3LiIVVUzrbA0ppNaV7U/MkBO366+jo2DB37txuAYAY0mqpv79/59NPP71969atRgfD5mBt1ZQuDgHl0IDSoJojIWhV9cADDzyUWv5bT1UFwHb63OeRI0e2v/rqq9ufeOKJsM+AaiDpc01F79AzEaeAUrrUp11+TRKBVHncdeONN3a3tbWtp7ICYAutlI4fP75n//79e0LsLaXT5TytuEbFkg49E3ELKEcky37ptLK6/fbbu6655pr19fX1XTU1NV0CAAWgVdKJEyd2Hj16tOeZZ57ZGXL5LtNpmWkdt3o5L5u4BpQj8qBy3HfffY2ppcCuNWvWdDc1NWlgtRNaAMLSMBofH+8ZGRnpOXz4cM8vf/nLPREHkiMW+0xu4h5QjrwFVSZdFly5cmX7ggUL2hsaGtrr6ura58yZ06iHK+qRIM4/BUBZcWaEaofduXPnTiWTyb7Ux06Njo72HTt2rO+zzz7r27FjR1+ewihdbCumTKUSUA4nqGolZDMFAMRILPeYvJRaQKXTakqfoTI+HBEAYkaX8fR8Pa2aSiaYHKUcUA4NKA0qqioApUCDSANJgykpJawcAiqdhlS9EFYA4qVsQilduQVUOg2puov/ZBkQgG3OyuUlvLIJpXTlHFDptJrSkKq7+E8CC0ChadedTniYvPjPkttT8ouAyk4nVjhBpRVWlRBaAKKhwaNhpEF0Nu1V9oGUiYDyZ06WV+XFV0Xaj204qRhAYZ2XyyFzLu3nU1leMPBf2AatVWx9ge0AAAAASUVORK5CYII=", "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 \r\n

Add gateway

\r\n \r\n \r\n
\r\n \r\n \r\n
\r\n
\r\n
\r\n \r\n Name\r\n \r\n \r\n Gateway name is required.\r\n \r\n \r\n
\r\n
\r\n \r\n
\r\n
\r\n
\r\n \r\n \r\n
\r\n
\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 af354acf62..6fb1e35156 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 @@ -145,3 +145,18 @@ DELETE FROM asset WHERE type='TbServiceQueue'; DELETE FROM asset_profile WHERE name ='TbServiceQueue'; -- QUEUE STATS UPDATE END + +-- MOBILE APP SETTINGS TABLE CREATE START + +CREATE TABLE IF NOT EXISTS mobile_app_settings ( + id uuid NOT NULL CONSTRAINT mobile_app_settings_pkey PRIMARY KEY, + created_time bigint NOT NULL, + tenant_id uuid NOT NULL, + use_default_app boolean, + android_config VARCHAR(1000), + ios_config VARCHAR(1000), + qr_code_config VARCHAR(100000), + CONSTRAINT mobile_app_settings_tenant_id_unq_key UNIQUE (tenant_id) +); + +-- MOBILE APP SETTINGS TABLE CREATE END \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java b/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java index 60f69cd8a7..6ec00b5661 100644 --- a/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java +++ b/application/src/main/java/org/thingsboard/server/ThingsboardServerApplication.java @@ -15,24 +15,32 @@ */ package org.thingsboard.server; +import lombok.extern.slf4j.Slf4j; import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringBootConfiguration; import org.springframework.context.annotation.ComponentScan; +import org.springframework.core.Ordered; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; +import org.thingsboard.server.queue.util.AfterStartUp; import java.util.Arrays; +import java.util.concurrent.TimeUnit; @SpringBootConfiguration @EnableAsync @EnableScheduling @ComponentScan({"org.thingsboard.server", "org.thingsboard.script"}) +@Slf4j public class ThingsboardServerApplication { private static final String SPRING_CONFIG_NAME_KEY = "--spring.config.name"; private static final String DEFAULT_SPRING_CONFIG_PARAM = SPRING_CONFIG_NAME_KEY + "=" + "thingsboard"; + private static long startTs; + public static void main(String[] args) { + startTs = System.currentTimeMillis(); SpringApplication.run(ThingsboardServerApplication.class, updateArguments(args)); } @@ -45,4 +53,11 @@ public class ThingsboardServerApplication { } return args; } + + @AfterStartUp(order = Ordered.LOWEST_PRECEDENCE) + public void afterStartUp() { + long startupTimeMs = System.currentTimeMillis() - startTs; + log.info("Started ThingsBoard in {} seconds", TimeUnit.MILLISECONDS.toSeconds(startupTimeMs)); + } + } 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 6c90e6e27d..a3111cb137 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -76,7 +76,7 @@ public class ThingsboardSecurityConfiguration { 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/**", "/.well-known/**"}; 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"; diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java index 2898689760..571f32d171 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -256,7 +256,9 @@ public class AuthController extends BaseController { } } - return tokenFactory.createTokenPair(securityUser); + var tokenPair = tokenFactory.createTokenPair(securityUser); + systemSecurityService.logLoginAction(user, new RestAuthenticationDetails(request), ActionType.LOGIN, null); + return tokenPair; } @ApiOperation(value = "Reset password (resetPassword)", diff --git a/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java index 5271de2329..6967b3c13b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java @@ -68,9 +68,9 @@ public class ComponentDescriptorController extends BaseController { @RequestMapping(value = "/components/{componentType}", method = RequestMethod.GET) @ResponseBody public List getComponentDescriptorsByType( - @Parameter(description = "Type of the Rule Node", schema = @Schema(allowableValues = "ENRICHMENT,FILTER,TRANSFORMATION,ACTION,EXTERNAL", requiredMode = Schema.RequiredMode.REQUIRED)) + @Parameter(description = "Type of the Rule Node", schema = @Schema(allowableValues = {"ENRICHMENT", "FILTER,TRANSFORMATION", "ACTION,EXTERNAL"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("componentType") String strComponentType, - @Parameter(description = "Type of the Rule Chain", schema = @Schema(allowableValues = "CORE,EDGE")) + @Parameter(description = "Type of the Rule Chain", schema = @Schema(allowableValues = {"CORE", "EDGE"})) @RequestParam(value = "ruleChainType", required = false) String strRuleChainType) throws ThingsboardException { checkParameter("componentType", strComponentType); return checkComponentDescriptorsByType(ComponentType.valueOf(strComponentType), getRuleChainType(strRuleChainType)); @@ -85,7 +85,7 @@ public class ComponentDescriptorController extends BaseController { public List getComponentDescriptorsByTypes( @Parameter(description = "List of types of the Rule Nodes, (ENRICHMENT, FILTER, TRANSFORMATION, ACTION or EXTERNAL)", required = true) @RequestParam("componentTypes") String[] strComponentTypes, - @Parameter(description = "Type of the Rule Chain", schema = @Schema(allowableValues = "CORE,EDGE")) + @Parameter(description = "Type of the Rule Chain", schema = @Schema(allowableValues = {"CORE", "EDGE"})) @RequestParam(value = "ruleChainType", required = false) String strRuleChainType) throws ThingsboardException { checkArrayParameter("componentTypes", strComponentTypes); Set componentTypes = new HashSet<>(); diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index a46cbe7dba..9ee8ae6cf8 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -164,7 +164,7 @@ public class CustomerController extends BaseController { @RequestParam int page, @Parameter(description = CUSTOMER_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "title", "email", "country, city"})) + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "title", "email", "country", "city"})) @RequestParam(required = false) String sortProperty, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index 8b33aeeb15..783b451a6d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -229,7 +229,7 @@ public class EntityViewController extends BaseController { @RequestParam(required = false) String type, @Parameter(description = ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name, type"})) + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "type"})) @RequestParam(required = false) String sortProperty, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { @@ -293,7 +293,7 @@ public class EntityViewController extends BaseController { @RequestParam(required = false) String type, @Parameter(description = ENTITY_VIEW_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name, type"})) + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "type"})) @RequestParam(required = false) String sortProperty, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java new file mode 100644 index 0000000000..626f6f3b0f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/MobileApplicationController.java @@ -0,0 +1,188 @@ +/** + * 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.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.AndroidConfig; +import org.thingsboard.server.common.data.mobile.IosConfig; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.mobile.MobileAppSettingsService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.mobile.secret.MobileAppSecretService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import java.net.URI; +import java.net.URISyntaxException; + +import static org.thingsboard.server.controller.ControllerConstants.AVAILABLE_FOR_ANY_AUTHORIZED_USER; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_AUTHORITY_PARAGRAPH; + +@RequiredArgsConstructor +@RestController +@TbCoreComponent +public class MobileApplicationController extends BaseController { + + @Value("${cache.specs.mobileSecretKey.timeToLiveInMinutes:2}") + private int mobileSecretKeyTtl; + + public static final String ASSET_LINKS_PATTERN = "[{\n" + + " \"relation\": [\"delegate_permission/common.handle_all_urls\"],\n" + + " \"target\": {\n" + + " \"namespace\": \"android_app\",\n" + + " \"package_name\": \"%s\",\n" + + " \"sha256_cert_fingerprints\":\n" + + " [\"%s\"]\n" + + " }\n" + + "}]"; + + public static final String APPLE_APP_SITE_ASSOCIATION_PATTERN = "{\n" + + " \"applinks\": {\n" + + " \"apps\": [],\n" + + " \"details\": [\n" + + " {\n" + + " \"appID\": \"%s\",\n" + + " \"paths\": [ \"/api/noauth/qr\" ]\n" + + " }\n" + + " ]\n" + + " }\n" + + "}"; + + public static final String ANDROID_APPLICATION_STORE_LINK = "https://play.google.com/store/apps/details?id=org.thingsboard.demo.app"; + public static final String APPLE_APPLICATION_STORE_LINK = "https://apps.apple.com/us/app/thingsboard-live/id1594355695"; + public static final String SECRET = "secret"; + public static final String SECRET_PARAM_DESCRIPTION = "A string value representing short-lived secret key"; + public static final String DEFAULT_APP_DOMAIN = "demo.thingsboard.io"; + public static final String DEEP_LINK_PATTERN = "https://%s/api/noauth/qr?secret=%s&ttl=%s"; + + private final SystemSecurityService systemSecurityService; + private final MobileAppSecretService mobileAppSecretService; + private final MobileAppSettingsService mobileAppSettingsService; + + @ApiOperation(value = "Get associated android applications (getAssetLinks)") + @GetMapping(value = "/.well-known/assetlinks.json") + public ResponseEntity getAssetLinks() { + MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); + AndroidConfig androidConfig = mobileAppSettings.getAndroidConfig(); + if (androidConfig != null && androidConfig.isEnabled() && !androidConfig.getAppPackage().isBlank() && !androidConfig.getSha256CertFingerprints().isBlank()) { + return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(ASSET_LINKS_PATTERN, androidConfig.getAppPackage(), androidConfig.getSha256CertFingerprints()))); + } else { + return ResponseEntity.notFound().build(); + } + } + + @ApiOperation(value = "Get associated ios applications (getAppleAppSiteAssociation)") + @GetMapping(value = "/.well-known/apple-app-site-association") + public ResponseEntity getAppleAppSiteAssociation() { + MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); + IosConfig iosConfig = mobileAppSettings.getIosConfig(); + if (iosConfig != null && iosConfig.isEnabled() && !iosConfig.getAppId().isBlank()) { + return ResponseEntity.ok(JacksonUtil.toJsonNode(String.format(APPLE_APP_SITE_ASSOCIATION_PATTERN, iosConfig.getAppId()))); + } else { + return ResponseEntity.notFound().build(); + } + } + + @ApiOperation(value = "Create Or Update the Mobile application settings (saveMobileAppSettings)", + notes = "The request payload contains configuration for android/iOS applications and platform qr code widget settings." + SYSTEM_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") + @PostMapping(value = "/api/mobile/app/settings") + public MobileAppSettings saveMobileAppSettings(@Parameter(description = "A JSON value representing the mobile apps configuration") + @RequestBody MobileAppSettings mobileAppSettings) throws ThingsboardException { + SecurityUser currentUser = getCurrentUser(); + accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.WRITE); + mobileAppSettings.setTenantId(getTenantId()); + return mobileAppSettingsService.saveMobileAppSettings(currentUser.getTenantId(), mobileAppSettings); + } + + @ApiOperation(value = "Get Mobile application settings (getMobileAppSettings)", + notes = "The response payload contains configuration for android/iOS applications and platform qr code widget settings." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/api/mobile/app/settings") + public MobileAppSettings getMobileAppSettings() throws ThingsboardException { + SecurityUser currentUser = getCurrentUser(); + accessControlService.checkPermission(currentUser, Resource.MOBILE_APP_SETTINGS, Operation.READ); + return mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); + } + + @ApiOperation(value = "Get the deep link to the associated mobile application (getMobileAppDeepLink)", + notes = "Fetch the url that takes user to linked mobile application " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/api/mobile/deepLink", produces = "text/plain") + public String getMobileAppDeepLink(HttpServletRequest request) throws ThingsboardException, URISyntaxException { + String secret = mobileAppSecretService.generateMobileAppSecret(getCurrentUser()); + String baseUrl = systemSecurityService.getBaseUrl(TenantId.SYS_TENANT_ID, null, request); + String platformDomain = new URI(baseUrl).getHost(); + MobileAppSettings mobileAppSettings = mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID); + String appDomain; + if (!mobileAppSettings.isUseDefaultApp()) { + appDomain = platformDomain; + } else { + appDomain = DEFAULT_APP_DOMAIN; + } + String deepLink = String.format(DEEP_LINK_PATTERN, appDomain, secret, mobileSecretKeyTtl); + if (!appDomain.equals(platformDomain)) { + deepLink = deepLink + "&host=" + baseUrl; + } + return "\"" + deepLink + "\""; + } + + @ApiOperation(value = "Get User Token (getUserTokenByMobileSecret)", + notes = "Returns the token of the User based on the provided secret key.") + @GetMapping(value = "/api/noauth/qr/{secret}") + public JwtPair getUserTokenByMobileSecret(@Parameter(description = SECRET_PARAM_DESCRIPTION) + @PathVariable(SECRET) String secret) throws ThingsboardException { + checkParameter(SECRET, secret); + return mobileAppSecretService.getJwtPair(secret); + } + + @GetMapping(value = "/api/noauth/qr") + public ResponseEntity getApplicationRedirect(@RequestHeader(value = "User-Agent") String userAgent) { + if (userAgent.contains("Android")) { + return ResponseEntity.status(HttpStatus.FOUND) + .header("Location", ANDROID_APPLICATION_STORE_LINK) + .build(); + } else if (userAgent.contains("iPhone") || userAgent.contains("iPad")) { + return ResponseEntity.status(HttpStatus.FOUND) + .header("Location", APPLE_APPLICATION_STORE_LINK) + .build(); + } else { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .build(); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java index 96f38daf35..fb32ebd30b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationController.java @@ -106,8 +106,6 @@ public class NotificationController extends BaseController { private final NotificationCenter notificationCenter; private final NotificationSettingsService notificationSettingsService; - private static final String DELIVERY_METHOD_ALLOWABLE_VALUES = "WEB,MOBILE_APP"; - @ApiOperation(value = "Get notifications (getNotifications)", notes = "Returns the page of notifications for current user." + NEW_LINE + PAGE_DATA_PARAMETERS + @@ -175,7 +173,7 @@ public class NotificationController extends BaseController { @RequestParam(required = false) String sortOrder, @Parameter(description = "To search for unread notifications only") @RequestParam(defaultValue = "false") boolean unreadOnly, - @Parameter(description = "Delivery method", schema = @Schema(allowableValues = {DELIVERY_METHOD_ALLOWABLE_VALUES})) + @Parameter(description = "Delivery method", schema = @Schema(allowableValues = {"WEB", "MOBILE_APP"})) @RequestParam(defaultValue = "WEB") NotificationDeliveryMethod deliveryMethod, @AuthenticationPrincipal SecurityUser user) throws ThingsboardException { // no permissions @@ -188,7 +186,7 @@ public class NotificationController extends BaseController { AVAILABLE_FOR_ANY_AUTHORIZED_USER) @GetMapping("/notifications/unread/count") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public Integer getUnreadNotificationsCount(@Parameter(description = "Delivery method", schema = @Schema(allowableValues = {DELIVERY_METHOD_ALLOWABLE_VALUES})) + public Integer getUnreadNotificationsCount(@Parameter(description = "Delivery method", schema = @Schema(allowableValues = {"WEB", "MOBILE_APP"})) @RequestParam(defaultValue = "MOBILE_APP") NotificationDeliveryMethod deliveryMethod, @AuthenticationPrincipal SecurityUser user) { return notificationService.countUnreadNotificationsByRecipientId(user.getTenantId(), deliveryMethod, user.getId()); @@ -211,7 +209,7 @@ public class NotificationController extends BaseController { AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PutMapping("/notifications/read") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - public void markAllNotificationsAsRead(@Parameter(description = "Delivery method", schema = @Schema(allowableValues = {DELIVERY_METHOD_ALLOWABLE_VALUES})) + public void markAllNotificationsAsRead(@Parameter(description = "Delivery method", schema = @Schema(allowableValues = {"WEB", "MOBILE_APP"})) @RequestParam(defaultValue = "WEB") NotificationDeliveryMethod deliveryMethod, @AuthenticationPrincipal SecurityUser user) { // no permissions diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java index c0a9d6d26f..325cc3b025 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java @@ -68,7 +68,7 @@ public class OAuth2Controller extends BaseController { @Parameter(description = "Platform type to search OAuth2 clients for which " + "the usage with this platform type is allowed in the settings. " + "If platform type is not one of allowable values - it will just be ignored", - schema = @Schema(allowableValues = "WEB, ANDROID, IOS")) + schema = @Schema(allowableValues = {"WEB", "ANDROID", "IOS"})) @RequestParam(required = false) String platform) throws ThingsboardException { if (log.isDebugEnabled()) { log.debug("Executing getOAuth2Clients: [{}][{}][{}]", request.getScheme(), request.getServerName(), request.getServerPort()); diff --git a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java index f2b77c3b7b..ee7bfcc0dc 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java @@ -198,7 +198,7 @@ public class OtaPackageController extends BaseController { @ResponseBody public PageData getOtaPackages(@Parameter(description = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) @PathVariable("deviceProfileId") String strDeviceProfileId, - @Parameter(description = "OTA Package type.", schema = @Schema(allowableValues = "FIRMWARE, SOFTWARE")) + @Parameter(description = "OTA Package type.", schema = @Schema(allowableValues = {"FIRMWARE", "SOFTWARE"})) @PathVariable("type") String strType, @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index cb2033b29d..bc12171306 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -34,10 +34,13 @@ import org.thingsboard.server.common.data.SystemParams; 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.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.mobile.QRCodeConfig; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.settings.UserSettings; import org.thingsboard.server.common.data.settings.UserSettingsType; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.dao.mobile.MobileAppSettingsService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; @@ -45,6 +48,7 @@ import org.thingsboard.server.service.sync.vc.EntitiesVersionControlService; import java.util.Collections; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; @Hidden @@ -72,6 +76,9 @@ public class SystemInfoController extends BaseController { @Autowired private EntitiesVersionControlService versionControlService; + @Autowired + private MobileAppSettingsService mobileAppSettingsService; + @PostConstruct public void init() { JsonNode info = buildInfoObject(); @@ -135,6 +142,9 @@ public class SystemInfoController extends BaseController { DefaultTenantProfileConfiguration tenantProfileConfiguration = tenantProfileCache.get(tenantId).getDefaultProfileConfiguration(); systemParams.setMaxResourceSize(tenantProfileConfiguration.getMaxResourceSize()); } + systemParams.setMobileQrEnabled(Optional.ofNullable(mobileAppSettingsService.getMobileAppSettings(TenantId.SYS_TENANT_ID)) + .map(MobileAppSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage) + .orElse(false)); return systemParams; } diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java index 8796b8c8a2..097aec6306 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java @@ -138,6 +138,9 @@ public class TenantProfileController extends BaseController { " \"transportDeviceMsgRateLimit\": \"20:1,600:60\",\n" + " \"transportDeviceTelemetryMsgRateLimit\": \"20:1,600:60\",\n" + " \"transportDeviceTelemetryDataPointsRateLimit\": \"20:1,600:60\",\n" + + " \"transportGatewayMsgRateLimit\": \"20:1,600:60\",\n" + + " \"transportGatewayTelemetryMsgRateLimit\": \"20:1,600:60\",\n" + + " \"transportGatewayTelemetryDataPointsRateLimit\": \"20:1,600:60\",\n" + " \"maxTransportMessages\": 10000000,\n" + " \"maxTransportDataPoints\": 10000000,\n" + " \"maxREExecutions\": 4000000,\n" + diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java index 71350bb1fd..69728b20c3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -539,6 +539,18 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke } } + @Override + public boolean isOpen(String externalId) { + String internalId = externalSessionMap.get(externalId); + if (internalId != null) { + SessionMetaData sessionMd = getSessionMd(internalId); + if (sessionMd != null) { + return sessionMd.session.isOpen(); + } + } + return false; + } + private boolean checkLimits(WebSocketSession session, WebSocketSessionRef sessionRef) throws IOException { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration == null) { diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java b/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java index 2d5026429a..9eac721dfb 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/BaseApiUsageState.java @@ -199,6 +199,16 @@ public abstract class BaseApiUsageState { return getApiUsageState().getEntityId(); } + @Override + public String toString() { + return "BaseApiUsageState{" + + "apiUsageState=" + apiUsageState + + ", currentCycleTs=" + currentCycleTs + + ", nextCycleTs=" + nextCycleTs + + ", currentHourTs=" + currentHourTs + + '}'; + } + @Data @Builder public static class StatsCalculationResult { diff --git a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java index aef3a4ddcf..60bf529ace 100644 --- a/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java +++ b/application/src/main/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateService.java @@ -17,6 +17,8 @@ package org.thingsboard.server.service.apiusage; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; @@ -67,8 +69,6 @@ import org.thingsboard.server.service.mail.MailExecutorService; import org.thingsboard.server.service.partition.AbstractPartitionBasedService; import org.thingsboard.server.service.telemetry.InternalTelemetryService; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -184,6 +184,9 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService if (newHourTs != hourTs) { usageState.setHour(newHourTs); } + if (log.isTraceEnabled()) { + log.trace("[{}][{}] Processing usage stats from {} (currentCycleTs={}, currentHourTs={}): {}", tenantId, ownerId, serviceId, ts, newHourTs, values); + } updatedEntries = new ArrayList<>(ApiUsageRecordKey.values().length); Set apiFeatures = new HashSet<>(); for (UsageStatsKVProto statsItem : values) { @@ -210,6 +213,7 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService } finally { updateLock.unlock(); } + log.trace("[{}][{}] Saving new stats: {}", tenantId, ownerId, updatedEntries); tsWsService.saveAndNotifyInternal(tenantId, usageState.getApiUsageState().getId(), updatedEntries, VOID_CALLBACK); if (!result.isEmpty()) { persistAndNotify(usageState, result); @@ -410,6 +414,9 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService myUsageStates.values().forEach(state -> { if ((state.getNextCycleTs() < now) && (now - state.getNextCycleTs() < TimeUnit.HOURS.toMillis(1))) { state.setCycles(state.getNextCycleTs(), SchedulerUtils.getStartOfNextNextMonth()); + if (log.isTraceEnabled()) { + log.trace("[{}][{}] Updating state cycles (currentCycleTs={},nextCycleTs={})", state.getTenantId(), state.getEntityId(), state.getCurrentCycleTs(), state.getNextCycleTs()); + } saveNewCounts(state, Arrays.asList(ApiUsageRecordKey.values())); if (state.getEntityType() == EntityType.TENANT && !state.getEntityId().equals(TenantId.SYS_TENANT_ID)) { TenantId tenantId = state.getTenantId(); @@ -417,6 +424,8 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService } } }); + } catch (Throwable e) { + log.error("Failed to check start of next cycle", e); } finally { updateLock.unlock(); } @@ -483,7 +492,7 @@ public class DefaultTbApiUsageStateService extends AbstractPartitionBasedService } } state.setGaugeReportInterval(gaugeReportInterval); - log.debug("[{}] Initialized state: {}", ownerId, storedState); + log.debug("[{}][{}] Initialized state: {}", tenantId, ownerId, state); TopicPartitionInfo tpi = partitionService.resolve(ServiceType.TB_CORE, tenantId, ownerId); if (tpi.isMyPartition()) { addEntityState(tpi, state); 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/entitiy/AbstractTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java index 0c91d72ea5..438cb58b91 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java @@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; +import org.springframework.core.env.Environment; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.User; @@ -39,11 +40,15 @@ import org.thingsboard.server.service.telemetry.AlarmSubscriptionService; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; @Slf4j public abstract class AbstractTbEntityService { + @Autowired + private Environment env; + @Value("${server.log_controller_error_stack_trace}") @Getter private boolean logControllerErrorStackTrace; @@ -67,6 +72,10 @@ public abstract class AbstractTbEntityService { @Lazy private EntitiesVersionControlService vcService; + protected boolean isTestProfile() { + return Set.of(this.env.getActiveProfiles()).contains("test"); + } + protected T checkNotNull(T reference) throws ThingsboardException { return checkNotNull(reference, "Requested item wasn't found!"); } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java index 8096722940..74061ee9e5 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/DefaultTbTenantService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.entitiy.tenant; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.TenantId; @@ -52,7 +53,9 @@ public class DefaultTbTenantService extends AbstractTbEntityService implements T Tenant savedTenant = tenantService.saveTenant(tenant, tenantId -> { installScripts.createDefaultRuleChains(tenantId); installScripts.createDefaultEdgeRuleChains(tenantId); - installScripts.createDefaultTenantDashboards(tenantId, null); + if (!isTestProfile()) { + installScripts.createDefaultTenantDashboards(tenantId, null); + } }); tenantProfileCache.evict(savedTenant.getId()); 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/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index 999079e913..9db2a99e7f 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -317,7 +317,8 @@ public class InstallScripts { @SneakyThrows public void loadSystemImages() { log.info("Loading system images..."); - Stream dashboardsFiles = Files.list(Paths.get(getDataDir(), JSON_DIR, DEMO_DIR, DASHBOARDS_DIR)); + Stream dashboardsFiles = Stream.concat(Files.list(Paths.get(getDataDir(), JSON_DIR, DEMO_DIR, DASHBOARDS_DIR)), + Files.list(Paths.get(getDataDir(), JSON_DIR, TENANT_DIR, DASHBOARDS_DIR))); try (dashboardsFiles) { dashboardsFiles.forEach(file -> { try { 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 06cbdc2230..b355ac32e9 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 @@ -39,6 +39,7 @@ 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; @@ -77,6 +78,8 @@ public class DefaultDataUpdateService implements DataUpdateService { @Autowired private CustomerService customerService; + @Autowired + private TenantProfileService tenantProfileService; @Override public void updateData(String fromVersion) throws Exception { @@ -88,12 +91,61 @@ public class DefaultDataUpdateService implements DataUpdateService { case "3.6.4": log.info("Updating data from version 3.6.4 to 3.7.0 ..."); updateCustomersWithTheSameTitle(); + updateMaxRuleNodeExecsPerMessage(); + updateGatewayRateLimits(); break; default: throw new RuntimeException("Unable to update data, unsupported fromVersion: " + fromVersion); } } + private void updateGatewayRateLimits() { + 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 -> { + boolean updated = false; + if (configuration.getTransportDeviceMsgRateLimit() != null && configuration.getTransportGatewayMsgRateLimit() == null) { + configuration.setTransportGatewayMsgRateLimit(configuration.getTransportDeviceMsgRateLimit()); + updated = true; + } + if (configuration.getTransportDeviceTelemetryMsgRateLimit() != null && configuration.getTransportGatewayTelemetryMsgRateLimit() == null) { + configuration.setTransportGatewayTelemetryMsgRateLimit(configuration.getTransportDeviceTelemetryMsgRateLimit()); + updated = true; + } + if (configuration.getTransportDeviceTelemetryDataPointsRateLimit() != null && configuration.getTransportGatewayTelemetryDataPointsRateLimit() == null) { + configuration.setTransportGatewayTelemetryDataPointsRateLimit(configuration.getTransportDeviceTelemetryDataPointsRateLimit()); + updated = true; + } + if (updated) { + 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 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 -> 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/mobile/secret/MobileAppSecretService.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretService.java new file mode 100644 index 0000000000..83c1b55c6e --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretService.java @@ -0,0 +1,28 @@ +/** + * 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.mobile.secret; + +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.service.security.model.SecurityUser; + +public interface MobileAppSecretService { + + String generateMobileAppSecret(SecurityUser securityUser); + + JwtPair getJwtPair(String secret) throws ThingsboardException; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java new file mode 100644 index 0000000000..bef31622ff --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileAppSecretServiceImpl.java @@ -0,0 +1,66 @@ +/** + * 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.mobile.secret; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; +import org.thingsboard.server.cache.TbCacheValueWrapper; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.dao.entity.AbstractCachedService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.token.JwtTokenFactory; +import org.thingsboard.server.service.security.system.SystemSecurityService; + +import static org.thingsboard.server.service.security.system.DefaultSystemSecurityService.DEFAULT_MOBILE_SECRET_KEY_LENGTH; + +@Service +@Slf4j +@RequiredArgsConstructor +public class MobileAppSecretServiceImpl extends AbstractCachedService implements MobileAppSecretService { + + private final JwtTokenFactory tokenFactory; + private final SystemSecurityService systemSecurityService; + + @Override + public String generateMobileAppSecret(SecurityUser securityUser) { + log.trace("Executing generateSecret for user [{}]", securityUser.getId()); + Integer mobileSecretKeyLength = systemSecurityService.getSecuritySettings().getMobileSecretKeyLength(); + String secret = StringUtils.generateSafeToken(mobileSecretKeyLength == null ? DEFAULT_MOBILE_SECRET_KEY_LENGTH : mobileSecretKeyLength); + cache.put(secret, tokenFactory.createTokenPair(securityUser)); + return secret; + } + + @Override + public JwtPair getJwtPair(String secret) throws ThingsboardException { + TbCacheValueWrapper jwtPair = cache.get(secret); + if (jwtPair != null) { + return jwtPair.get(); + } else { + throw new ThingsboardException("Jwt token not found or expired!", ThingsboardErrorCode.JWT_TOKEN_EXPIRED); + } + } + + @TransactionalEventListener(classes = MobileSecretEvictEvent.class) + @Override + public void handleEvictEvent(MobileSecretEvictEvent event) { + cache.evict(event.getSecret()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretCaffeineCache.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretCaffeineCache.java new file mode 100644 index 0000000000..ce21041d27 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretCaffeineCache.java @@ -0,0 +1,33 @@ +/** + * 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.mobile.secret; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.security.model.JwtPair; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("MobileSecretCache") +public class MobileSecretCaffeineCache extends CaffeineTbTransactionalCache { + + public MobileSecretCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.MOBILE_SECRET_KEY_CACHE); + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretEvictEvent.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretEvictEvent.java new file mode 100644 index 0000000000..b015291d78 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretEvictEvent.java @@ -0,0 +1,25 @@ +/** + * 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.mobile.secret; + +import lombok.Data; + +@Data +public class MobileSecretEvictEvent { + + private final String secret; + +} diff --git a/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java new file mode 100644 index 0000000000..1ff6be61a3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/mobile/secret/MobileSecretRedisCache.java @@ -0,0 +1,35 @@ +/** + * 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.mobile.secret; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbJsonRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.security.model.JwtPair; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("MobileSecretCache") +public class MobileSecretRedisCache extends RedisTbTransactionalCache { + + public MobileSecretRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.MOBILE_SECRET_KEY_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(JwtPair.class)); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 42c516a8a3..fbef0cfd29 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -20,12 +20,12 @@ import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import lombok.Data; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -43,6 +43,7 @@ import org.thingsboard.server.common.data.id.NotificationRequestId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; +import org.thingsboard.server.common.data.queue.QueueConfig; import org.thingsboard.server.common.data.rpc.RpcError; import org.thingsboard.server.common.msg.MsgType; import org.thingsboard.server.common.msg.TbActorMsg; @@ -78,10 +79,11 @@ import org.thingsboard.server.gen.transport.TransportProtos.ToUsageStatsServiceM import org.thingsboard.server.gen.transport.TransportProtos.TransportToDeviceActorMsg; 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.QueueKey; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; 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.apiusage.TbApiUsageStateService; import org.thingsboard.server.service.edge.EdgeNotificationService; @@ -89,6 +91,7 @@ import org.thingsboard.server.service.notification.NotificationSchedulerService; import org.thingsboard.server.service.ota.OtaPackageStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; +import org.thingsboard.server.service.queue.consumer.MainQueueConsumerManager; import org.thingsboard.server.service.queue.processing.AbstractConsumerService; import org.thingsboard.server.service.queue.processing.IdMsgPair; import org.thingsboard.server.service.resource.TbImageService; @@ -109,7 +112,6 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; @@ -122,9 +124,11 @@ import java.util.stream.Collectors; public class DefaultTbCoreConsumerService extends AbstractConsumerService 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, @@ -176,10 +179,7 @@ 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 { @@ -353,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(); @@ -409,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) { @@ -782,15 +749,17 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService consumers = new ConcurrentHashMap<>(); public DefaultTbRuleEngineConsumerService(TbRuleEngineConsumerContext ctx, - TbRuleEngineQueueFactory tbRuleEngineQueueFactory, ActorSystemContext actorContext, TbRuleEngineDeviceRpcService tbDeviceRpcService, QueueService queueService, @@ -88,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; @@ -97,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())) { @@ -137,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 @@ -168,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(); @@ -244,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 fe15b457f2..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,9 +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 org.thingsboard.server.service.security.model.token.JwtTokenFactory; - import java.util.List; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -57,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; @@ -64,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; @@ -74,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 @@ -98,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) { @@ -203,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 e696192a43..63be2d89da 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.Objects; import java.util.Set; @@ -31,23 +30,23 @@ import java.util.concurrent.TimeUnit; import java.util.function.Supplier; @Slf4j -public class TbQueueConsumerTask { +public class TbQueueConsumerTask { @Getter private final Object key; - private volatile TbQueueConsumer> consumer; - private volatile Supplier>> consumerSupplier; + private volatile TbQueueConsumer consumer; + private volatile Supplier> consumerSupplier; @Setter private Future task; - public TbQueueConsumerTask(Object key, Supplier>> consumerSupplier) { + public TbQueueConsumerTask(Object key, Supplier> consumerSupplier) { this.key = key; this.consumer = null; this.consumerSupplier = consumerSupplier; } - public TbQueueConsumer> getConsumer() { + public TbQueueConsumer getConsumer() { if (consumer == null) { synchronized (this) { if (consumer == null) { 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 0fb005b928..8a96f2c0e1 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,171 +37,52 @@ 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); + @Override + protected void processTask(TbQueueConsumerManagerTask task) { + if (task.getEvent() == QueueEvent.DELETE) { + doDelete(task.isDrainQueue()); } - - 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 - } - } - - 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) { @@ -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,7 +242,7 @@ 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); 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 f2e276a0ef..a47d0034a7 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 @@ -210,11 +210,12 @@ public class JwtTokenFactory { ZonedDateTime currentTime = ZonedDateTime.now(); + claimsBuilder.expiration(Date.from(currentTime.plusSeconds(expirationTime).toInstant())); + return Jwts.builder() .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); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java index d26e49e4ab..af1b34ae71 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/CustomerUserPermissions.java @@ -49,6 +49,7 @@ public class CustomerUserPermissions extends AbstractPermissions { put(Resource.DEVICE_PROFILE, profilePermissionChecker); put(Resource.ASSET_PROFILE, profilePermissionChecker); put(Resource.TB_RESOURCE, customerResourcePermissionChecker); + put(Resource.MOBILE_APP_SETTINGS, new PermissionChecker.GenericPermissionChecker(Operation.READ)); } private static final PermissionChecker customerAlarmPermissionChecker = new PermissionChecker() { diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java index 5aa869f2a8..d43663fb97 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -46,8 +46,8 @@ public enum Resource { QUEUE(EntityType.QUEUE), VERSION_CONTROL, NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE, - EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE); - + EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE), + MOBILE_APP_SETTINGS; private final Set entityTypes; Resource() { diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java index 67cc105c91..d906938e47 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/SysAdminPermissions.java @@ -41,6 +41,7 @@ public class SysAdminPermissions extends AbstractPermissions { put(Resource.TB_RESOURCE, systemEntityPermissionChecker); put(Resource.QUEUE, systemEntityPermissionChecker); put(Resource.NOTIFICATION, systemEntityPermissionChecker); + put(Resource.MOBILE_APP_SETTINGS, PermissionChecker.allowAllPermissionChecker); } private static final PermissionChecker systemEntityPermissionChecker = new PermissionChecker() { diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index bfe0af966f..10807e4b5a 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -50,6 +50,7 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.QUEUE, queuePermissionChecker); put(Resource.VERSION_CONTROL, PermissionChecker.allowAllPermissionChecker); put(Resource.NOTIFICATION, tenantEntityPermissionChecker); + put(Resource.MOBILE_APP_SETTINGS, new PermissionChecker.GenericPermissionChecker(Operation.READ)); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index d3f945cda1..b12e6d4581 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -57,7 +57,6 @@ import org.thingsboard.server.dao.user.UserService; import org.thingsboard.server.dao.user.UserServiceImpl; import org.thingsboard.server.service.security.auth.rest.RestAuthenticationDetails; import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; -import org.thingsboard.server.service.security.exception.UserPasswordNotValidException; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.utils.MiscUtils; import ua_parser.Client; @@ -75,6 +74,8 @@ import static org.thingsboard.server.common.data.CacheConstants.SECURITY_SETTING @Slf4j public class DefaultSystemSecurityService implements SystemSecurityService { + public static final int DEFAULT_MOBILE_SECRET_KEY_LENGTH = 64; + @Autowired private AdminSettingsService adminSettingsService; @@ -109,6 +110,7 @@ public class DefaultSystemSecurityService implements SystemSecurityService { securitySettings.setPasswordPolicy(new UserPasswordPolicy()); securitySettings.getPasswordPolicy().setMinimumLength(6); securitySettings.getPasswordPolicy().setMaximumLength(72); + securitySettings.setMobileSecretKeyLength(DEFAULT_MOBILE_SECRET_KEY_LENGTH); } return securitySettings; } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java index 10e342b1e8..984af019b4 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java @@ -95,7 +95,8 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc private static final int DEFAULT_LIMIT = 100; private final Map> subscriptionsBySessionId = new ConcurrentHashMap<>(); - @Autowired @Lazy + @Autowired + @Lazy private WebSocketService wsService; @Autowired @@ -728,7 +729,14 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc public void cancelAllSessionSubscriptions(String sessionId) { Map sessionSubs = subscriptionsBySessionId.remove(sessionId); if (sessionSubs != null) { - sessionSubs.values().forEach(this::cleanupAndCancel); + sessionSubs.values().forEach(sub -> { + try { + cleanupAndCancel(sub); + } catch (Exception e) { + log.warn("[{}] Failed to remove subscription {} due to ", sub.getTenantId(), sub, e); + } + } + ); } } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java index bcb900c3f0..8abb78d9c1 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java @@ -45,6 +45,7 @@ import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; import org.thingsboard.server.queue.discovery.event.ClusterTopologyChangeEvent; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.ws.WebSocketService; import org.thingsboard.server.service.ws.notification.sub.NotificationRequestUpdate; import org.thingsboard.server.service.ws.notification.sub.NotificationsSubscriptionUpdate; import org.thingsboard.server.service.ws.telemetry.sub.AlarmSubscriptionUpdate; @@ -62,6 +63,8 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; @@ -84,18 +87,21 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer private final PartitionService partitionService; private final TbClusterService clusterService; private final SubscriptionManagerService subscriptionManagerService; + private final WebSocketService webSocketService; private ExecutorService tsCallBackExecutor; + private ScheduledExecutorService staleSessionCleanupExecutor; public DefaultTbLocalSubscriptionService(AttributesService attrService, TimeseriesService tsService, TbServiceInfoProvider serviceInfoProvider, PartitionService partitionService, TbClusterService clusterService, - @Lazy SubscriptionManagerService subscriptionManagerService) { + @Lazy SubscriptionManagerService subscriptionManagerService, @Lazy WebSocketService webSocketService) { this.attrService = attrService; this.tsService = tsService; this.serviceInfoProvider = serviceInfoProvider; this.partitionService = partitionService; this.clusterService = clusterService; this.subscriptionManagerService = subscriptionManagerService; + this.webSocketService = webSocketService; } private String serviceId; @@ -108,6 +114,8 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer subscriptionUpdateExecutor = ThingsBoardExecutors.newWorkStealingPool(20, getClass()); tsCallBackExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("ts-sub-callback")); serviceId = serviceInfoProvider.getServiceId(); + staleSessionCleanupExecutor = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("stale-session-cleanup")); + staleSessionCleanupExecutor.scheduleWithFixedDelay(this::cleanupStaleSessions, 60, 60, TimeUnit.SECONDS); } @PreDestroy @@ -118,6 +126,9 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer if (tsCallBackExecutor != null) { tsCallBackExecutor.shutdownNow(); } + if (staleSessionCleanupExecutor != null) { + staleSessionCleanupExecutor.shutdownNow(); + } } @Override @@ -157,9 +168,18 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer TenantId tenantId = subscription.getTenantId(); EntityId entityId = subscription.getEntityId(); log.debug("[{}][{}] Register subscription: {}", tenantId, entityId, subscription); - Map> sessionSubscriptions = subscriptionsBySessionId.computeIfAbsent(subscription.getSessionId(), k -> new ConcurrentHashMap<>()); - sessionSubscriptions.put(subscription.getSubscriptionId(), subscription); - modifySubscription(tenantId, entityId, subscription, true); + SubscriptionModificationResult result; + subsLock.lock(); + try { + Map> sessionSubscriptions = subscriptionsBySessionId.computeIfAbsent(subscription.getSessionId(), k -> new ConcurrentHashMap<>()); + sessionSubscriptions.put(subscription.getSubscriptionId(), subscription); + result = modifySubscription(tenantId, entityId, subscription, true); + } finally { + subsLock.unlock(); + } + if (result.hasEvent()) { + pushSubscriptionEvent(result); + } } @Override @@ -195,33 +215,49 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer @Override public void cancelSubscription(String sessionId, int subscriptionId) { log.debug("[{}][{}] Going to remove subscription.", sessionId, subscriptionId); - Map> sessionSubscriptions = subscriptionsBySessionId.get(sessionId); - if (sessionSubscriptions != null) { - TbSubscription subscription = sessionSubscriptions.remove(subscriptionId); - if (subscription != null) { - if (sessionSubscriptions.isEmpty()) { - subscriptionsBySessionId.remove(sessionId); + SubscriptionModificationResult result = null; + subsLock.lock(); + try { + Map> sessionSubscriptions = subscriptionsBySessionId.get(sessionId); + if (sessionSubscriptions != null) { + TbSubscription subscription = sessionSubscriptions.remove(subscriptionId); + if (subscription != null) { + if (sessionSubscriptions.isEmpty()) { + subscriptionsBySessionId.remove(sessionId); + } + result = modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false); + } else { + log.debug("[{}][{}] Subscription not found!", sessionId, subscriptionId); } - modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false); } else { - log.debug("[{}][{}] Subscription not found!", sessionId, subscriptionId); + log.debug("[{}] No session subscriptions found!", sessionId); } - } else { - log.debug("[{}] No session subscriptions found!", sessionId); + } finally { + subsLock.unlock(); + } + if (result != null && result.hasEvent()) { + pushSubscriptionEvent(result); } } @Override public void cancelAllSessionSubscriptions(String sessionId) { log.debug("[{}] Going to remove session subscriptions.", sessionId); - Map> sessionSubscriptions = subscriptionsBySessionId.remove(sessionId); - if (sessionSubscriptions != null) { - for (TbSubscription subscription : sessionSubscriptions.values()) { - modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false); + List results = new ArrayList<>(); + subsLock.lock(); + try { + Map> sessionSubscriptions = subscriptionsBySessionId.remove(sessionId); + if (sessionSubscriptions != null) { + for (TbSubscription subscription : sessionSubscriptions.values()) { + results.add(modifySubscription(subscription.getTenantId(), subscription.getEntityId(), subscription, false)); + } + } else { + log.debug("[{}] No session subscriptions found!", sessionId); } - } else { - log.debug("[{}] No session subscriptions found!", sessionId); + } finally { + subsLock.unlock(); } + results.stream().filter(SubscriptionModificationResult::hasEvent).forEach(this::pushSubscriptionEvent); } @Override @@ -384,10 +420,9 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer callback.onSuccess(); } - private void modifySubscription(TenantId tenantId, EntityId entityId, TbSubscription subscription, boolean add) { + private SubscriptionModificationResult modifySubscription(TenantId tenantId, EntityId entityId, TbSubscription subscription, boolean add) { TbSubscription missedUpdatesCandidate = null; - TbEntitySubEvent event; - subsLock.lock(); + TbEntitySubEvent event = null; try { TbEntityLocalSubsInfo entitySubs = subscriptionsByEntityId.computeIfAbsent(entityId.getId(), id -> new TbEntityLocalSubsInfo(tenantId, entityId)); event = add ? entitySubs.add(subscription) : entitySubs.remove(subscription); @@ -397,17 +432,23 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer } else if (add) { missedUpdatesCandidate = entitySubs.registerPendingSubscription(subscription, event); } - } finally { - subsLock.unlock(); + } catch (Exception e) { + log.warn("[{}][{}] Failed to {} subscription {} due to ", tenantId, entityId, add ? "add" : "remove", subscription, e); } - if (event != null) { - log.trace("[{}][{}][{}] Event: {}", tenantId, entityId, subscription.getSubscriptionId(), event); - pushSubEventToManagerService(tenantId, entityId, event); + return new SubscriptionModificationResult(tenantId, entityId, subscription, missedUpdatesCandidate, event); + } + + private void pushSubscriptionEvent(SubscriptionModificationResult modificationResult) { + try { + TbEntitySubEvent event = modificationResult.getEvent(); + log.trace("[{}][{}][{}] Event: {}", modificationResult.getTenantId(), modificationResult.getEntityId(), modificationResult.getSubscription().getSubscriptionId(), event); + pushSubEventToManagerService(modificationResult.getTenantId(), modificationResult.getEntityId(), event); + TbSubscription missedUpdatesCandidate = modificationResult.getMissedUpdatesCandidate(); if (missedUpdatesCandidate != null) { checkMissedUpdates(missedUpdatesCandidate); } - } else { - log.trace("[{}][{}][{}] No changes detected.", tenantId, entityId, subscription.getSubscriptionId()); + } catch (Exception e) { + log.warn("[{}][{}] Failed to push subscription event {} due to ", modificationResult.getTenantId(), modificationResult.getEntityId(), modificationResult.getEvent(), e); } } @@ -518,4 +559,8 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer } } + private void cleanupStaleSessions() { + subscriptionsBySessionId.keySet().forEach(webSocketService::cleanupIfStale); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionModificationResult.java b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionModificationResult.java new file mode 100644 index 0000000000..a31ecedb8f --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/subscription/SubscriptionModificationResult.java @@ -0,0 +1,39 @@ +/** + * 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.subscription; + +import lombok.Builder; +import lombok.Data; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; + +/** + * The modification result of entity subscription + */ +@Builder +@Data +public class SubscriptionModificationResult { + + private TenantId tenantId; + private EntityId entityId; + private TbSubscription subscription; + private TbSubscription missedUpdatesCandidate; + private TbEntitySubEvent event; + + public boolean hasEvent() { + return event != null; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index e41d1821c1..2a53e9a772 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -197,7 +197,8 @@ public class DefaultWebSocketService implements WebSocketService { wsSessionsMap.put(sessionId, new WsSessionMetaData(sessionRef)); break; case ERROR: - log.debug("[{}] Unknown websocket session error: {}. ", sessionId, event.getError().orElse(null)); + log.debug("[{}] Unknown websocket session error: ", sessionId, + event.getError().orElse(new RuntimeException("No error specified"))); break; case CLOSED: wsSessionsMap.remove(sessionId); @@ -294,6 +295,16 @@ public class DefaultWebSocketService implements WebSocketService { } } + @Override + public void cleanupIfStale(String sessionId) { + if (!msgEndpoint.isOpen(sessionId)) { + log.info("[{}] Cleaning up stale session ", sessionId); + wsSessionsMap.remove(sessionId); + oldSubService.cancelAllSessionSubscriptions(sessionId); + entityDataSubService.cancelAllSessionSubscriptions(sessionId); + } + } + private void processSessionClose(WebSocketSessionRef sessionRef) { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketMsgEndpoint.java b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketMsgEndpoint.java index 41a68c98ca..416af080f8 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketMsgEndpoint.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketMsgEndpoint.java @@ -29,4 +29,6 @@ public interface WebSocketMsgEndpoint { void sendPing(WebSocketSessionRef sessionRef, long currentTime) throws IOException; void close(WebSocketSessionRef sessionRef, CloseStatus withReason) throws IOException; + + boolean isOpen(String sessionId); } diff --git a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketService.java index 8f586f264e..284431ff84 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketService.java @@ -36,4 +36,7 @@ public interface WebSocketService { void sendError(WebSocketSessionRef sessionRef, int subId, SubscriptionErrorCode errorCode, String errorMsg); void close(String sessionId, CloseStatus status); + + void cleanupIfStale(String sessionId); + } diff --git a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketSessionRef.java b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketSessionRef.java index 190183cce2..44919b138c 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/WebSocketSessionRef.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/WebSocketSessionRef.java @@ -33,7 +33,7 @@ public class WebSocketSessionRef { private static final long serialVersionUID = 1L; private final String sessionId; - private SecurityUser securityCtx; + private volatile SecurityUser securityCtx; private final InetSocketAddress localAddress; private final InetSocketAddress remoteAddress; private final WebSocketSessionType sessionType; diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 191d810c64..25a2d617b7 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: @@ -585,6 +587,12 @@ cache: alarmTypes: timeToLiveInMinutes: "${CACHE_SPECS_ALARM_TYPES_TTL:60}" # Alarm types cache TTL maxSize: "${CACHE_SPECS_ALARM_TYPES_MAX_SIZE:10000}" # 0 means the cache is disabled + mobileAppSettings: + timeToLiveInMinutes: "${CACHE_SPECS_MOBILE_APP_SETTINGS_TTL:1440}" # Mobile application cache TTL + maxSize: "${CACHE_SPECS_MOBILE_APP_SETTINGS_MAX_SIZE:10000}" # 0 means the cache is disabled + mobileSecretKey: + timeToLiveInMinutes: "${CACHE_MOBILE_SECRET_KEY_TTL:2}" # QR secret key cache TTL + maxSize: "${CACHE_MOBILE_SECRET_KEY_MAX_SIZE:10000}" # 0 means the cache is disabled # Deliberately placed outside the 'specs' group above notificationRules: @@ -1627,6 +1635,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/AuditLogControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java index 31f398dc03..5005e6ad34 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java @@ -122,7 +122,7 @@ public class AuditLogControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Assert.assertEquals(11, loadedAuditLogs.size()); + Assert.assertEquals(11 + 1, loadedAuditLogs.size()); loadedAuditLogs = new ArrayList<>(); pageLink = new TimePageLink(5); @@ -136,7 +136,7 @@ public class AuditLogControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Assert.assertEquals(11, loadedAuditLogs.size()); + Assert.assertEquals(11 + 1, loadedAuditLogs.size()); loadedAuditLogs = new ArrayList<>(); pageLink = new TimePageLink(5); @@ -150,7 +150,7 @@ public class AuditLogControllerTest extends AbstractControllerTest { } } while (pageData.hasNext()); - Assert.assertEquals(11, loadedAuditLogs.size()); + Assert.assertEquals(11 + 1, loadedAuditLogs.size()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java index 31c82408c1..55af29ab0b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/HomePageApiTest.java @@ -92,7 +92,7 @@ public class HomePageApiTest extends AbstractControllerTest { @MockBean private SmsService smsService; - private static final int DEFAULT_DASHBOARDS_COUNT = 1; + private static final int DEFAULT_DASHBOARDS_COUNT = 0; //For system administrator @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java new file mode 100644 index 0000000000..39e2ef6a40 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/MobileApplicationControllerTest.java @@ -0,0 +1,253 @@ +/** + * 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.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.junit.Before; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Value; +import org.thingsboard.server.common.data.mobile.AndroidConfig; +import org.thingsboard.server.common.data.mobile.IosConfig; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.mobile.QRCodeConfig; +import org.thingsboard.server.common.data.security.model.JwtPair; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +@DaoSqlTest +public class MobileApplicationControllerTest extends AbstractControllerTest { + + @Value("${cache.specs.mobileSecretKey.timeToLiveInMinutes:2}") + private int mobileSecretKeyTtl; + private static final String ANDROID_PACKAGE_NAME = "testAppPackage"; + private static final String ANDROID_APP_SHA256 = "DF:28:32:66:8B:A7:D3:EC:7D:73:CF:CC"; + private static final String APPLE_APP_ID = "testId"; + private static final String TEST_LABEL = "Test label"; + + @Before + public void setUp() throws Exception { + loginSysAdmin(); + + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + QRCodeConfig qrCodeConfig = new QRCodeConfig(); + qrCodeConfig.setQrCodeLabel(TEST_LABEL); + + mobileAppSettings.setUseDefaultApp(true); + AndroidConfig androidConfig = AndroidConfig.builder() + .appPackage(ANDROID_PACKAGE_NAME) + .sha256CertFingerprints(ANDROID_APP_SHA256) + .enabled(true) + .build(); + + IosConfig iosConfig = IosConfig.builder() + .appId(APPLE_APP_ID) + .enabled(true) + .build(); + mobileAppSettings.setAndroidConfig(androidConfig); + mobileAppSettings.setIosConfig(iosConfig); + mobileAppSettings.setQrCodeConfig(qrCodeConfig); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + } + + @Test + public void testSaveMobileAppSettings() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + assertThat(mobileAppSettings.getQrCodeConfig().getQrCodeLabel()).isEqualTo(TEST_LABEL); + assertThat(mobileAppSettings.isUseDefaultApp()).isTrue(); + + mobileAppSettings.setUseDefaultApp(false); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + + MobileAppSettings updatedMobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + assertThat(updatedMobileAppSettings.isUseDefaultApp()).isFalse(); + } + + @Test + public void testShouldNotSaveMobileAppSettingsWithoutRequiredConfig() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + + mobileAppSettings.setUseDefaultApp(false); + mobileAppSettings.setAndroidConfig(null); + mobileAppSettings.setIosConfig(null); + mobileAppSettings.setQrCodeConfig(null); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Android/ios settings are required to use custom application!"))); + + mobileAppSettings.setAndroidConfig(AndroidConfig.builder().enabled(false).build()); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Android/ios settings are required to use custom application!"))); + + mobileAppSettings.setIosConfig(IosConfig.builder().enabled(false).build()); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Qr code configuration is required!"))); + + mobileAppSettings.setQrCodeConfig(QRCodeConfig.builder().showOnHomePage(false).build()); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + } + + @Test + public void testShouldNotSaveMobileAppSettingsWithoutRequiredAndroidConf() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + mobileAppSettings.setUseDefaultApp(false); + AndroidConfig androidConfig = AndroidConfig.builder() + .enabled(true) + .appPackage(null) + .sha256CertFingerprints(null) + .build(); + mobileAppSettings.setAndroidConfig(androidConfig); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Application package and sha256 cert fingerprints are required for custom android application!"))); + + androidConfig.setAppPackage("test_app_package"); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Application package and sha256 cert fingerprints are required for custom android application!"))); + + androidConfig.setSha256CertFingerprints("test_sha_256"); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + } + + @Test + public void testShouldNotSaveMobileAppSettingsWithoutRequiredIosConf() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + mobileAppSettings.setUseDefaultApp(false); + IosConfig iosConfig = IosConfig.builder() + .enabled(true) + .appId(null) + .build(); + mobileAppSettings.setIosConfig(iosConfig); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("Application id is required for custom ios application!"))); + + iosConfig.setAppId("test_app_id"); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + } + + @Test + public void testShouldSaveMobileAppSettingsForDefaultApp() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + mobileAppSettings.setUseDefaultApp(true); + mobileAppSettings.setIosConfig(null); + mobileAppSettings.setAndroidConfig(null); + + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + } + + @Test + public void testGetApplicationAssociations() throws Exception { + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + mobileAppSettings.setUseDefaultApp(false); + doPost("/api/mobile/app/settings", mobileAppSettings) + .andExpect(status().isOk()); + + JsonNode assetLinks = doGet("/.well-known/assetlinks.json", JsonNode.class); + assertThat(assetLinks.get(0).get("target").get("package_name").asText()).isEqualTo(ANDROID_PACKAGE_NAME); + assertThat(assetLinks.get(0).get("target").get("sha256_cert_fingerprints").get(0).asText()).isEqualTo(ANDROID_APP_SHA256); + + JsonNode appleAssociation = doGet("/.well-known/apple-app-site-association", JsonNode.class); + assertThat(appleAssociation.get("applinks").get("details").get(0).get("appID").asText()).isEqualTo(APPLE_APP_ID); + } + + @Test + public void testGetMobileDeepLink() throws Exception { + loginSysAdmin(); + String deepLink = doGet("/api/mobile/deepLink", String.class); + + Pattern expectedPattern = Pattern.compile("\"https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)&host=([^&]+)\""); + Matcher parsedDeepLink = expectedPattern.matcher(deepLink); + assertThat(parsedDeepLink.matches()).isTrue(); + String appHost = parsedDeepLink.group(1); + String secret = parsedDeepLink.group(2); + String ttl = parsedDeepLink.group(3); + assertThat(appHost).isEqualTo("demo.thingsboard.io"); + assertThat(ttl).isEqualTo(String.valueOf(mobileSecretKeyTtl)); + + JwtPair jwtPair = doGet("/api/noauth/qr/" + secret, JwtPair.class); + assertThat(jwtPair).isNotNull(); + + loginTenantAdmin(); + String tenantDeepLink = doGet("/api/mobile/deepLink", String.class); + Matcher tenantParsedDeepLink = expectedPattern.matcher(tenantDeepLink); + assertThat(tenantParsedDeepLink.matches()).isTrue(); + String tenantSecret = tenantParsedDeepLink.group(2); + + JwtPair tenantJwtPair = doGet("/api/noauth/qr/" + tenantSecret, JwtPair.class); + assertThat(tenantJwtPair).isNotNull(); + + loginCustomerUser(); + String customerDeepLink = doGet("/api/mobile/deepLink", String.class); + Matcher customerParsedDeepLink = expectedPattern.matcher(customerDeepLink); + assertThat(customerParsedDeepLink.matches()).isTrue(); + String customerSecret = customerParsedDeepLink.group(2); + + JwtPair customerJwtPair = doGet("/api/noauth/qr/" + customerSecret, JwtPair.class); + assertThat(customerJwtPair).isNotNull(); + + // update mobile setting to use custom one + loginSysAdmin(); + MobileAppSettings mobileAppSettings = doGet("/api/mobile/app/settings", MobileAppSettings.class); + mobileAppSettings.setUseDefaultApp(false); + doPost("/api/mobile/app/settings", mobileAppSettings); + + String customAppDeepLink = doGet("/api/mobile/deepLink", String.class); + Pattern customAppExpectedPattern = Pattern.compile("\"https://([^/]+)/api/noauth/qr\\?secret=([^&]+)&ttl=([^&]+)\""); + Matcher customAppParsedDeepLink = customAppExpectedPattern.matcher(customAppDeepLink); + assertThat(customAppParsedDeepLink.matches()).isTrue(); + assertThat(customAppParsedDeepLink.group(1)).isEqualTo("localhost"); + + loginTenantAdmin(); + String tenantCustomAppDeepLink = doGet("/api/mobile/deepLink", String.class); + Matcher tenantCustomAppParsedDeepLink = customAppExpectedPattern.matcher(tenantCustomAppDeepLink); + assertThat(tenantCustomAppParsedDeepLink.matches()).isTrue(); + assertThat(tenantCustomAppParsedDeepLink.group(1)).isEqualTo("localhost"); + + loginCustomerUser(); + String customerCustomAppDeepLink = doGet("/api/mobile/deepLink", String.class); + Matcher customerCustomAppParsedDeepLink = customAppExpectedPattern.matcher(customerCustomAppDeepLink); + assertThat(customerCustomAppParsedDeepLink.matches()).isTrue(); + assertThat(customerCustomAppParsedDeepLink.group(1)).isEqualTo("localhost"); + } +} diff --git a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java index 38286589bd..63703ae27b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TwoFactorAuthTest.java @@ -298,15 +298,13 @@ public class TwoFactorAuthTest extends AbstractControllerTest { logInWithPreVerificationToken(username, password); await("async audit log saving").during(1, TimeUnit.SECONDS); - assertThat(getLogInAuditLogs()).isEmpty(); - assertThat(userService.findUserById(tenantId, user.getId()).getAdditionalInfo() - .get("lastLoginTs")).isNull(); doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=incorrect") .andExpect(status().isBadRequest()); + // there is the first login audit log after user activation await("async audit log saving").atMost(1, TimeUnit.SECONDS) - .until(() -> getLogInAuditLogs().size() == 1); + .until(() -> getLogInAuditLogs().size() == 2); assertThat(getLogInAuditLogs().get(0)).satisfies(failedLogInAuditLog -> { assertThat(failedLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.FAILURE); assertThat(failedLogInAuditLog.getActionFailureDetails()).containsIgnoringCase("verification code is incorrect"); @@ -316,7 +314,7 @@ public class TwoFactorAuthTest extends AbstractControllerTest { doPost("/api/auth/2fa/verification/check?providerType=TOTP&verificationCode=" + getCorrectTotp(totpTwoFaAccountConfig)) .andExpect(status().isOk()); await("async audit log saving").atMost(1, TimeUnit.SECONDS) - .until(() -> getLogInAuditLogs().size() == 2); + .until(() -> getLogInAuditLogs().size() == 3); assertThat(getLogInAuditLogs().get(0)).satisfies(successfulLogInAuditLog -> { assertThat(successfulLogInAuditLog.getActionStatus()).isEqualTo(ActionStatus.SUCCESS); assertThat(successfulLogInAuditLog.getUserName()).isEqualTo(username); diff --git a/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java b/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java index 4d389b4210..dbef55539f 100644 --- a/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/queue/discovery/HashPartitionServiceTest.java @@ -20,12 +20,12 @@ import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.ListUtils; import org.apache.commons.lang3.RandomStringUtils; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeEach; import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.common.data.DataConstants; @@ -67,7 +67,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @Slf4j -@RunWith(MockitoJUnitRunner.class) +@MockitoSettings(strictness = Strictness.LENIENT) public class HashPartitionServiceTest { public static final int ITERATIONS = 1000000; @@ -82,7 +82,7 @@ public class HashPartitionServiceTest { private String hashFunctionName = "murmur3_128"; - @Before + @BeforeEach public void setup() throws Exception { serviceInfoProvider = mock(TbServiceInfoProvider.class); applicationEventPublisher = mock(ApplicationEventPublisher.class); @@ -173,7 +173,7 @@ public class HashPartitionServiceTest { for (Map.Entry entry : data) { System.out.println(entry.getKey() + ": " + entry.getValue()); } - Assert.assertTrue(diffPercent < maxDiffPercent); + Assertions.assertTrue(diffPercent < maxDiffPercent); } @Test diff --git a/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java b/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java index e32e3aaa57..051214c771 100644 --- a/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/apiusage/DefaultTbApiUsageStateServiceTest.java @@ -15,24 +15,16 @@ */ package org.thingsboard.server.service.apiusage; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.Spy; -import org.mockito.junit.MockitoJUnitRunner; -import org.thingsboard.rule.engine.api.MailService; -import org.thingsboard.server.cluster.TbClusterService; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.dao.tenant.TbTenantProfileCache; -import org.thingsboard.server.dao.tenant.TenantService; -import org.thingsboard.server.dao.timeseries.TimeseriesService; -import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; -import org.thingsboard.server.queue.discovery.PartitionService; -import org.thingsboard.server.service.executors.DbCallbackExecutorService; import java.util.UUID; @@ -40,27 +32,11 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; import static org.mockito.Mockito.never; -@RunWith(MockitoJUnitRunner.class) +@ExtendWith(MockitoExtension.class) public class DefaultTbApiUsageStateServiceTest { - @Mock - TenantService tenantService; - @Mock - TimeseriesService tsService; - @Mock - TbClusterService clusterService; - @Mock - PartitionService partitionService; @Mock TenantApiUsageState tenantUsageStateMock; - @Mock - ApiUsageStateService apiUsageStateService; - @Mock - TbTenantProfileCache tenantProfileCache; - @Mock - MailService mailService; - @Mock - DbCallbackExecutorService dbExecutor; TenantId tenantId = TenantId.fromUUID(UUID.fromString("00797a3b-7aeb-4b5b-b57a-c2a810d0f112")); @@ -68,7 +44,7 @@ public class DefaultTbApiUsageStateServiceTest { @InjectMocks DefaultTbApiUsageStateService service; - @Before + @BeforeEach public void setUp() { } diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructorTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructorTest.java index d6bfbf1036..ec43753596 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructorTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/constructor/RuleChainMsgConstructorTest.java @@ -17,11 +17,11 @@ package org.thingsboard.server.service.edge.rpc.constructor; import com.fasterxml.jackson.databind.JsonNode; import lombok.extern.slf4j.Slf4j; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.junit.MockitoJUnitRunner; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; @@ -44,7 +44,7 @@ import java.util.Optional; import java.util.UUID; @Slf4j -@RunWith(MockitoJUnitRunner.class) +@ExtendWith(MockitoExtension.class) public class RuleChainMsgConstructorTest { private static final String RPC_CONNECTION_TYPE = "RPC"; @@ -53,7 +53,7 @@ public class RuleChainMsgConstructorTest { private TenantId tenantId; - @Before + @BeforeEach public void setup() { ruleChainMsgConstructorV1 = new RuleChainMsgConstructorV1(); tenantId = new TenantId(UUID.randomUUID()); @@ -98,10 +98,10 @@ public class RuleChainMsgConstructorTest { } private void assetV_3_3_3_and_V_3_4_0(RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg) { - Assert.assertEquals("First rule node index incorrect!", 3, ruleChainMetadataUpdateMsg.getFirstNodeIndex()); - Assert.assertEquals("Nodes count incorrect!", 12, ruleChainMetadataUpdateMsg.getNodesCount()); - Assert.assertEquals("Connections count incorrect!", 13, ruleChainMetadataUpdateMsg.getConnectionsCount()); - Assert.assertEquals("Rule chain connections count incorrect!", 0, ruleChainMetadataUpdateMsg.getRuleChainConnectionsCount()); + Assertions.assertEquals(3, ruleChainMetadataUpdateMsg.getFirstNodeIndex(), "First rule node index incorrect!"); + Assertions.assertEquals(12, ruleChainMetadataUpdateMsg.getNodesCount(), "Nodes count incorrect!"); + Assertions.assertEquals(13, ruleChainMetadataUpdateMsg.getConnectionsCount(), "Connections count incorrect!"); + Assertions.assertEquals(0, ruleChainMetadataUpdateMsg.getRuleChainConnectionsCount(), "Rule chain connections count incorrect!"); compareNodeConnectionInfoAndProto(createNodeConnectionInfo(3, 6, TbNodeConnectionType.SUCCESS), ruleChainMetadataUpdateMsg.getConnections(0)); compareNodeConnectionInfoAndProto(createNodeConnectionInfo(3, 10, TbNodeConnectionType.SUCCESS), ruleChainMetadataUpdateMsg.getConnections(1)); @@ -129,10 +129,10 @@ public class RuleChainMsgConstructorTest { ruleChainMetaData, EdgeVersion.V_3_3_0); - Assert.assertEquals("First rule node index incorrect!", 2, ruleChainMetadataUpdateMsg.getFirstNodeIndex()); - Assert.assertEquals("Nodes count incorrect!", 10, ruleChainMetadataUpdateMsg.getNodesCount()); - Assert.assertEquals("Connections count incorrect!", 10, ruleChainMetadataUpdateMsg.getConnectionsCount()); - Assert.assertEquals("Rule chain connections count incorrect!", 1, ruleChainMetadataUpdateMsg.getRuleChainConnectionsCount()); + Assertions.assertEquals(2, ruleChainMetadataUpdateMsg.getFirstNodeIndex(),"First rule node index incorrect!"); + Assertions.assertEquals(10, ruleChainMetadataUpdateMsg.getNodesCount(), "Nodes count incorrect!"); + Assertions.assertEquals(10, ruleChainMetadataUpdateMsg.getConnectionsCount(),"Connections count incorrect!"); + Assertions.assertEquals(1, ruleChainMetadataUpdateMsg.getRuleChainConnectionsCount(), "Rule chain connections count incorrect!"); compareNodeConnectionInfoAndProto(createNodeConnectionInfo(2, 5, TbNodeConnectionType.SUCCESS), ruleChainMetadataUpdateMsg.getConnections(0)); compareNodeConnectionInfoAndProto(createNodeConnectionInfo(3, 9, TbNodeConnectionType.SUCCESS), ruleChainMetadataUpdateMsg.getConnections(1)); @@ -146,13 +146,12 @@ public class RuleChainMsgConstructorTest { compareNodeConnectionInfoAndProto(createNodeConnectionInfo(6, 9, TbNodeConnectionType.SUCCESS), ruleChainMetadataUpdateMsg.getConnections(9)); RuleChainConnectionInfoProto ruleChainConnection = ruleChainMetadataUpdateMsg.getRuleChainConnections(0); - Assert.assertEquals("From index incorrect!", 2, ruleChainConnection.getFromIndex()); - Assert.assertEquals("Type index incorrect!", TbNodeConnectionType.SUCCESS, ruleChainConnection.getType()); - Assert.assertEquals("Additional info incorrect!", - "{\"description\":\"\",\"layoutX\":477,\"layoutY\":560,\"ruleChainNodeId\":\"rule-chain-node-UNDEFINED\"}", - ruleChainConnection.getAdditionalInfo()); - Assert.assertTrue("Target rule chain id MSB incorrect!", ruleChainConnection.getTargetRuleChainIdMSB() != 0); - Assert.assertTrue("Target rule chain id LSB incorrect!", ruleChainConnection.getTargetRuleChainIdLSB() != 0); + Assertions.assertEquals(2, ruleChainConnection.getFromIndex(), "From index incorrect!"); + Assertions.assertEquals(TbNodeConnectionType.SUCCESS, ruleChainConnection.getType(), "Type index incorrect!"); + Assertions.assertEquals("{\"description\":\"\",\"layoutX\":477,\"layoutY\":560,\"ruleChainNodeId\":\"rule-chain-node-UNDEFINED\"}", + ruleChainConnection.getAdditionalInfo(),"Additional info incorrect!"); + Assertions.assertTrue(ruleChainConnection.getTargetRuleChainIdMSB() != 0,"Target rule chain id MSB incorrect!"); + Assertions.assertTrue(ruleChainConnection.getTargetRuleChainIdLSB() != 0,"Target rule chain id LSB incorrect!"); assertCheckpointRuleNodeConfiguration( ruleChainMetadataUpdateMsg.getNodesList(), @@ -171,10 +170,10 @@ public class RuleChainMsgConstructorTest { ruleChainMetaData1, EdgeVersion.V_3_3_0); - Assert.assertEquals("First rule node index incorrect!", 7, ruleChainMetadataUpdateMsg.getFirstNodeIndex()); - Assert.assertEquals("Nodes count incorrect!", 10, ruleChainMetadataUpdateMsg.getNodesCount()); - Assert.assertEquals("Connections count incorrect!", 10, ruleChainMetadataUpdateMsg.getConnectionsCount()); - Assert.assertEquals("Rule chain connections count incorrect!", 1, ruleChainMetadataUpdateMsg.getRuleChainConnectionsCount()); + Assertions.assertEquals(7, ruleChainMetadataUpdateMsg.getFirstNodeIndex(), "First rule node index incorrect!"); + Assertions.assertEquals(10, ruleChainMetadataUpdateMsg.getNodesCount(),"Nodes count incorrect!"); + Assertions.assertEquals(10, ruleChainMetadataUpdateMsg.getConnectionsCount(),"Connections count incorrect!"); + Assertions.assertEquals(1, ruleChainMetadataUpdateMsg.getRuleChainConnectionsCount(),"Rule chain connections count incorrect!"); compareNodeConnectionInfoAndProto(createNodeConnectionInfo(3, 0, TbNodeConnectionType.SUCCESS), ruleChainMetadataUpdateMsg.getConnections(0)); compareNodeConnectionInfoAndProto(createNodeConnectionInfo(4, 0, TbMsgType.ATTRIBUTES_UPDATED.getRuleNodeConnection()), ruleChainMetadataUpdateMsg.getConnections(1)); @@ -188,13 +187,12 @@ public class RuleChainMsgConstructorTest { compareNodeConnectionInfoAndProto(createNodeConnectionInfo(7, 4, TbNodeConnectionType.SUCCESS), ruleChainMetadataUpdateMsg.getConnections(9)); RuleChainConnectionInfoProto ruleChainConnection = ruleChainMetadataUpdateMsg.getRuleChainConnections(0); - Assert.assertEquals("From index incorrect!", 7, ruleChainConnection.getFromIndex()); - Assert.assertEquals("Type index incorrect!", TbNodeConnectionType.SUCCESS, ruleChainConnection.getType()); - Assert.assertEquals("Additional info incorrect!", - "{\"description\":\"\",\"layoutX\":477,\"layoutY\":560,\"ruleChainNodeId\":\"rule-chain-node-UNDEFINED\"}", - ruleChainConnection.getAdditionalInfo()); - Assert.assertTrue("Target rule chain id MSB incorrect!", ruleChainConnection.getTargetRuleChainIdMSB() != 0); - Assert.assertTrue("Target rule chain id LSB incorrect!", ruleChainConnection.getTargetRuleChainIdLSB() != 0); + Assertions.assertEquals(7, ruleChainConnection.getFromIndex(),"From index incorrect!"); + Assertions.assertEquals(TbNodeConnectionType.SUCCESS, ruleChainConnection.getType(), "Type index incorrect!"); + Assertions.assertEquals( "{\"description\":\"\",\"layoutX\":477,\"layoutY\":560,\"ruleChainNodeId\":\"rule-chain-node-UNDEFINED\"}", + ruleChainConnection.getAdditionalInfo(), "Additional info incorrect!"); + Assertions.assertTrue(ruleChainConnection.getTargetRuleChainIdMSB() != 0, "Target rule chain id MSB incorrect!"); + Assertions.assertTrue(ruleChainConnection.getTargetRuleChainIdLSB() != 0, "Target rule chain id LSB incorrect!"); assertCheckpointRuleNodeConfiguration( ruleChainMetadataUpdateMsg.getNodesList(), @@ -206,15 +204,15 @@ public class RuleChainMsgConstructorTest { Optional checkpointRuleNodeOpt = nodesList.stream() .filter(rn -> "org.thingsboard.rule.engine.flow.TbCheckpointNode".equals(rn.getType())) .findFirst(); - Assert.assertTrue(checkpointRuleNodeOpt.isPresent()); + Assertions.assertTrue(checkpointRuleNodeOpt.isPresent()); RuleNodeProto checkpointRuleNode = checkpointRuleNodeOpt.get(); - Assert.assertEquals(expectedConfiguration, checkpointRuleNode.getConfiguration()); + Assertions.assertEquals(expectedConfiguration, checkpointRuleNode.getConfiguration()); } private void compareNodeConnectionInfoAndProto(NodeConnectionInfo expected, org.thingsboard.server.gen.edge.v1.NodeConnectionInfoProto actual) { - Assert.assertEquals(expected.getFromIndex(), actual.getFromIndex()); - Assert.assertEquals(expected.getToIndex(), actual.getToIndex()); - Assert.assertEquals(expected.getType(), actual.getType()); + Assertions.assertEquals(expected.getFromIndex(), actual.getFromIndex()); + Assertions.assertEquals(expected.getToIndex(), actual.getToIndex()); + Assertions.assertEquals(expected.getType(), actual.getType()); } private RuleChainMetaData createRuleChainMetaData(RuleChainId ruleChainId, Integer firstNodeIndex, List nodes, List connections) { diff --git a/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java b/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java index 95a2c63f52..2e3dd8ee26 100644 --- a/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/limits/RateLimitServiceTest.java @@ -15,11 +15,12 @@ */ package org.thingsboard.server.service.limits; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mockito; -import org.mockito.junit.MockitoJUnitRunner; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.server.cache.limits.DefaultRateLimitService; import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.common.data.TenantProfile; @@ -35,21 +36,19 @@ import org.thingsboard.server.dao.tenant.DefaultTbTenantProfileCache; import java.util.List; import java.util.UUID; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.reset; import static org.mockito.Mockito.when; -@RunWith(MockitoJUnitRunner.class) +@ExtendWith(MockitoExtension.class) public class RateLimitServiceTest { private RateLimitService rateLimitService; private DefaultTbTenantProfileCache tenantProfileCache; private TenantId tenantId; - @Before + @BeforeEach public void beforeEach() { tenantProfileCache = Mockito.mock(DefaultTbTenantProfileCache.class); rateLimitService = new DefaultRateLimitService(tenantProfileCache, mock(NotificationRuleProcessor.class), 60, 100); @@ -102,10 +101,10 @@ public class RateLimitServiceTest { private void testRateLimits(LimitedApi limitedApi, int max, Object level) { for (int i = 1; i <= max; i++) { boolean success = rateLimitService.checkRateLimit(limitedApi, tenantId, level); - assertTrue(success); + Assertions.assertTrue(success); } boolean success = rateLimitService.checkRateLimit(limitedApi, tenantId, level); - assertFalse(success); + Assertions.assertFalse(success); } private void updateTenantProfileConfiguration(DefaultTenantProfileConfiguration profileConfiguration) { diff --git a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java index ae9dbe92c4..15650ff2e6 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java @@ -21,6 +21,7 @@ import org.junit.After; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.data.util.Pair; +import org.springframework.jdbc.core.JdbcTemplate; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.server.common.data.User; @@ -102,6 +103,8 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest protected SqlPartitioningRepository partitioningRepository; @Autowired protected DefaultNotifications defaultNotifications; + @Autowired + private JdbcTemplate jdbcTemplate; public static final String DEFAULT_NOTIFICATION_SUBJECT = "Just a test"; public static final NotificationType DEFAULT_NOTIFICATION_TYPE = NotificationType.GENERAL; @@ -112,7 +115,8 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest notificationRuleService.deleteNotificationRulesByTenantId(TenantId.SYS_TENANT_ID); notificationTemplateService.deleteNotificationTemplatesByTenantId(TenantId.SYS_TENANT_ID); notificationTargetService.deleteNotificationTargetsByTenantId(TenantId.SYS_TENANT_ID); - partitioningRepository.dropPartitionsBefore("notification", Long.MAX_VALUE, 1); + jdbcTemplate.execute("TRUNCATE TABLE notification"); + partitioningRepository.cleanupPartitionsCache("notification", Long.MAX_VALUE, 0); notificationSettingsService.deleteNotificationSettings(TenantId.SYS_TENANT_ID); } diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java index 1a788d437b..883a173edb 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationApiTest.java @@ -331,7 +331,7 @@ public class NotificationApiTest extends AbstractNotificationApiTest { @Test public void testNotificationUpdatesForSeveralUsers() throws Exception { - int usersCount = 150; + int usersCount = 50; Map sessions = new HashMap<>(); List targets = new ArrayList<>(); diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java index 33b79cc82c..35b5eaeb51 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java @@ -21,14 +21,16 @@ import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.testcontainers.shaded.org.apache.commons.lang3.RandomUtils; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.QueueId; @@ -69,6 +71,9 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; @@ -92,7 +97,6 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; @@ -119,6 +123,9 @@ public class TbRuleEngineQueueConsumerManagerTest { @Mock private TbQueueAdmin queueAdmin; private TbRuleEngineConsumerContext ruleEngineConsumerContext; + private ExecutorService consumersExecutor; + private ScheduledExecutorService scheduler; + private ExecutorService mgmtExecutor; private TbRuleEngineQueueConsumerManager consumerManager; private Queue queue; @@ -148,10 +155,10 @@ public class TbRuleEngineQueueConsumerManagerTest { }).when(actorContext).tell(any()); when(producerProvider.getRuleEngineMsgProducer()).thenReturn(ruleEngineMsgProducer); - ruleEngineConsumerContext.setMgmtThreadPoolSize(2); + consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("tb-rule-engine-consumer")); + mgmtExecutor = ThingsBoardExecutors.newWorkStealingPool(3, "tb-rule-engine-mgmt"); + scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("tb-rule-engine-consumer-scheduler")); ruleEngineConsumerContext.setTopicDeletionDelayInSec(5); - ruleEngineConsumerContext.init(); - ruleEngineConsumerContext.setReady(false); queue = new Queue(); queue.setName("Test"); @@ -181,14 +188,23 @@ public class TbRuleEngineQueueConsumerManagerTest { }).when(queueFactory).createToRuleEngineMsgConsumer(any(), any()); QueueKey queueKey = new QueueKey(ServiceType.TB_RULE_ENGINE, queue); - consumerManager = new TbRuleEngineQueueConsumerManager(ruleEngineConsumerContext, queueKey); + consumerManager = TbRuleEngineQueueConsumerManager.create() + .ctx(ruleEngineConsumerContext) + .queueKey(queueKey) + .consumerExecutor(consumersExecutor) + .scheduler(scheduler) + .taskExecutor(mgmtExecutor) + .build(); } @AfterEach public void afterEach() { consumerManager.stop(); consumerManager.awaitStop(); - ruleEngineConsumerContext.stop(); + + consumersExecutor.shutdownNow(); + scheduler.shutdownNow(); + mgmtExecutor.shutdownNow(); if (generateQueueMsgs) { await().atMost(10, TimeUnit.SECONDS) @@ -220,14 +236,7 @@ public class TbRuleEngineQueueConsumerManagerTest { Set partitions = createTpis(2, 3, 4); consumerManager.update(partitions); - partitions = createTpis(3, 4, 5); - consumerManager.update(partitions); - partitions = createTpis(1, 2, 3); - consumerManager.update(partitions); - // simulated multiple partition change events before consumer is ready; only latest partitions should be processed - verifyNoInteractions(queueFactory); - ruleEngineConsumerContext.setReady(true); await().atMost(2, TimeUnit.SECONDS) .until(() -> consumers.size() == 3); for (TopicPartitionInfo partition : partitions) { @@ -243,14 +252,7 @@ public class TbRuleEngineQueueConsumerManagerTest { Set partitions = createTpis(2, 3, 4); consumerManager.update(partitions); - partitions = createTpis(3, 4, 5); - consumerManager.update(partitions); - partitions = createTpis(1, 2, 3); - consumerManager.update(partitions); - - verifyNoInteractions(queueFactory); - ruleEngineConsumerContext.setReady(true); await().atMost(2, TimeUnit.SECONDS) .until(() -> consumers.size() == 1); TestConsumer consumer = getConsumer(); @@ -261,7 +263,6 @@ public class TbRuleEngineQueueConsumerManagerTest { public void testPartitionsUpdate_singleConsumer() { queue.setConsumerPerPartition(false); consumerManager.init(queue); - ruleEngineConsumerContext.setReady(true); Set partitions = Collections.emptySet(); consumerManager.update(partitions); @@ -295,7 +296,6 @@ public class TbRuleEngineQueueConsumerManagerTest { public void testPartitionsUpdate_consumerPerPartition() { queue.setConsumerPerPartition(true); consumerManager.init(queue); - ruleEngineConsumerContext.setReady(true); consumerManager.update(Collections.emptySet()); verify(queueFactory, after(1000).never()).createToRuleEngineMsgConsumer(any(), any()); @@ -339,7 +339,6 @@ public class TbRuleEngineQueueConsumerManagerTest { public void testConfigUpdate_singleConsumer() { queue.setConsumerPerPartition(false); consumerManager.init(queue); - ruleEngineConsumerContext.setReady(true); Set partitions = createTpis(1, 2, 3); consumerManager.update(partitions); TestConsumer consumer = getConsumer(); @@ -365,7 +364,6 @@ public class TbRuleEngineQueueConsumerManagerTest { public void testConfigUpdate_consumerPerPartition() { queue.setConsumerPerPartition(true); consumerManager.init(queue); - ruleEngineConsumerContext.setReady(true); Set partitions = createTpis(1, 2, 3); consumerManager.update(partitions); TestConsumer consumer1 = getConsumer(1); @@ -398,7 +396,6 @@ public class TbRuleEngineQueueConsumerManagerTest { public void testConfigUpdate_fromSingleToConsumerPerPartition() { queue.setConsumerPerPartition(false); consumerManager.init(queue); - ruleEngineConsumerContext.setReady(true); Set partitions = createTpis(1, 2, 3); consumerManager.update(partitions); TestConsumer consumer = getConsumer(); @@ -418,7 +415,6 @@ public class TbRuleEngineQueueConsumerManagerTest { public void testConfigUpdate_fromConsumerPerPartitionToSingle() { queue.setConsumerPerPartition(true); consumerManager.init(queue); - ruleEngineConsumerContext.setReady(true); Set partitions = createTpis(1, 2, 3); consumerManager.update(partitions); TestConsumer consumer1 = getConsumer(1); @@ -442,7 +438,6 @@ public class TbRuleEngineQueueConsumerManagerTest { public void testStop() { queue.setConsumerPerPartition(true); consumerManager.init(queue); - ruleEngineConsumerContext.setReady(true); consumerManager.update(createTpis(1)); TestConsumer consumer = getConsumer(1); verifySubscribedAndLaunched(consumer, 1); @@ -461,7 +456,6 @@ public class TbRuleEngineQueueConsumerManagerTest { public void testDelete_consumerPerPartition() { queue.setConsumerPerPartition(true); consumerManager.init(queue); - ruleEngineConsumerContext.setReady(true); Set partitions = createTpis(1, 2); consumerManager.update(partitions); TestConsumer consumer1 = getConsumer(1); @@ -483,7 +477,7 @@ public class TbRuleEngineQueueConsumerManagerTest { int msgCount = totalConsumedMsgs.get(); await().atLeast(2, TimeUnit.SECONDS) // based on topicDeletionDelayInSec(5) = 5 - ( 3 seconds the code may execute starting consumerManager.delete() call) - .atMost(7, TimeUnit.SECONDS) + .atMost(20, TimeUnit.SECONDS) .untilAsserted(() -> { partitions.stream() .map(TopicPartitionInfo::getFullTopicName) @@ -505,7 +499,6 @@ public class TbRuleEngineQueueConsumerManagerTest { public void testDelete_singleConsumer() { queue.setConsumerPerPartition(false); consumerManager.init(queue); - ruleEngineConsumerContext.setReady(true); Set partitions = createTpis(1, 2); consumerManager.update(partitions); TestConsumer consumer = getConsumer(); @@ -523,7 +516,7 @@ public class TbRuleEngineQueueConsumerManagerTest { int msgCount = totalConsumedMsgs.get(); await().atLeast(2, TimeUnit.SECONDS) // based on topicDeletionDelayInSec(5) = 5 - ( 3 seconds the code may execute starting consumerManager.delete() call) - .atMost(7, TimeUnit.SECONDS) + .atMost(20, TimeUnit.SECONDS) .untilAsserted(() -> { partitions.stream() .map(TopicPartitionInfo::getFullTopicName) @@ -544,10 +537,9 @@ public class TbRuleEngineQueueConsumerManagerTest { public void testManyDifferentUpdates() throws Exception { queue.setConsumerPerPartition(RandomUtils.nextBoolean()); consumerManager.init(queue); - ruleEngineConsumerContext.setReady(true); Supplier queueConfigUpdater = () -> { - Queue oldConfig = consumerManager.getQueue(); + Queue oldConfig = consumerManager.getConfig(); Queue newConfig = JacksonUtil.clone(oldConfig); newConfig.setConsumerPerPartition(RandomUtils.nextBoolean()); newConfig.setPollInterval(RandomUtils.nextInt(100, 501)); @@ -595,7 +587,7 @@ public class TbRuleEngineQueueConsumerManagerTest { Set expectedPartitions = latestPartitions; await().atMost(5, TimeUnit.SECONDS) .untilAsserted(() -> { - assertThat(consumerManager.getQueue()).isEqualTo(expectedConfig); + assertThat(consumerManager.getConfig()).isEqualTo(expectedConfig); assertThat(consumerManager.getPartitions()).isEqualTo(expectedPartitions); }); diff --git a/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java b/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java index 96a3daa06d..7db9e01bc8 100644 --- a/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sms/smpp/SmppSmsSenderTest.java @@ -15,10 +15,10 @@ */ package org.thingsboard.server.service.sms.smpp; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.junit.MockitoJUnitRunner; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; import org.smpp.Session; import org.smpp.pdu.SubmitSMResp; import org.thingsboard.server.common.data.StringUtils; @@ -35,14 +35,14 @@ import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@RunWith(MockitoJUnitRunner.class) +@ExtendWith(MockitoExtension.class) public class SmppSmsSenderTest { SmppSmsSender smppSmsSender; SmppSmsProviderConfiguration smppConfig; Session smppSession; - @Before + @BeforeEach public void beforeEach() throws Exception { Constructor constructor = SmppSmsSender.class.getDeclaredConstructor(); constructor.setAccessible(true); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java new file mode 100644 index 0000000000..794d93a44a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java @@ -0,0 +1,171 @@ +/** + * 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.transport.mqtt; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.TestPropertySource; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.notification.rule.trigger.RateLimitsTrigger; +import org.thingsboard.server.common.data.security.DeviceCredentials; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; +import org.thingsboard.server.controller.AbstractControllerTest; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.transport.mqtt.mqttv3.MqttTestClient; + +import java.util.function.Consumer; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.ArgumentMatchers.eq; +import static org.thingsboard.server.common.data.limit.LimitedApi.TRANSPORT_MESSAGES_PER_GATEWAY; + +@DaoSqlTest +@TestPropertySource(properties = { + "service.integrations.supported=ALL", + "transport.mqtt.enabled=true", +}) +public class MqttGatewayRateLimitsTest extends AbstractControllerTest { + + private static final String TOPIC = "v1/gateway/telemetry"; + private static final String DEVICE_A = "DeviceA"; + private static final String DEVICE_B = "DeviceB"; + + private DeviceId gatewayId; + private String gatewayAccessToken; + + @SpyBean + private NotificationRuleProcessor notificationRuleProcessor; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + TenantProfile tenantProfile = doGet("/api/tenantProfile/" + tenantProfileId, TenantProfile.class); + Assert.assertNotNull(tenantProfile); + + DefaultTenantProfileConfiguration profileConfiguration = (DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration(); + + profileConfiguration.setTransportGatewayMsgRateLimit(null); + profileConfiguration.setTransportGatewayTelemetryMsgRateLimit(null); + profileConfiguration.setTransportGatewayTelemetryDataPointsRateLimit(null); + + doPost("/api/tenantProfile", tenantProfile); + + loginTenantAdmin(); + createGateway(); + + Mockito.reset(notificationRuleProcessor); + } + + @Test + public void transportGatewayMsgRateLimitTest() throws Exception { + transportGatewayRateLimitTest(profileConfiguration -> profileConfiguration.setTransportGatewayMsgRateLimit("1:600")); + } + + @Test + public void transportGatewayTelemetryMsgRateLimitTest() throws Exception { + transportGatewayRateLimitTest(profileConfiguration -> profileConfiguration.setTransportGatewayTelemetryMsgRateLimit("1:600")); + } + + @Test + public void transportGatewayTelemetryDataPointsRateLimitTest() throws Exception { + transportGatewayRateLimitTest(profileConfiguration -> profileConfiguration.setTransportGatewayTelemetryDataPointsRateLimit("1:600")); + } + + private void transportGatewayRateLimitTest(Consumer profileConfiguration) throws Exception { + loginSysAdmin(); + + TenantProfile tenantProfile = doGet("/api/tenantProfile/" + tenantProfileId, TenantProfile.class); + Assert.assertNotNull(tenantProfile); + + profileConfiguration.accept((DefaultTenantProfileConfiguration) tenantProfile.getProfileData().getConfiguration()); + + doPost("/api/tenantProfile", tenantProfile); + + MqttTestClient client = new MqttTestClient(); + client.connectAndWait(gatewayAccessToken); + client.publishAndWait(TOPIC, getGatewayPayload(DEVICE_A)); + + loginTenantAdmin(); + + Device deviceA = getDeviceByName(DEVICE_A); + + var deviceATrigger = createRateLimitsTrigger(deviceA); + + Mockito.verify(notificationRuleProcessor, Mockito.never()).process(eq(deviceATrigger)); + + try { + client.publishAndWait(TOPIC, getGatewayPayload(DEVICE_B)); + } catch (Exception t) { + } + + Device deviceB = getDeviceByName(DEVICE_B); + + var deviceBTrigger = createRateLimitsTrigger(deviceB); + + Mockito.verify(notificationRuleProcessor, Mockito.times(1)).process(deviceBTrigger); + + if (client.isConnected()) { + client.disconnect(); + } + } + + private void createGateway() throws Exception { + Device device = new Device(); + device.setName("gateway"); + ObjectNode additionalInfo = JacksonUtil.newObjectNode(); + additionalInfo.put("gateway", true); + device.setAdditionalInfo(additionalInfo); + device = doPost("/api/device", device, Device.class); + assertNotNull(device); + gatewayId = device.getId(); + assertNotNull(gatewayId); + + DeviceCredentials deviceCredentials = doGet("/api/device/" + gatewayId + "/credentials", DeviceCredentials.class); + assertNotNull(deviceCredentials); + assertEquals(gatewayId, deviceCredentials.getDeviceId()); + gatewayAccessToken = deviceCredentials.getCredentialsId(); + assertNotNull(gatewayAccessToken); + } + + private Device getDeviceByName(String deviceName) throws Exception { + Device device = doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class); + assertNotNull(device); + return device; + } + + private byte[] getGatewayPayload(String deviceName) { + return String.format("{\"%s\": [{\"values\": {\"temperature\": 42}}]}", deviceName).getBytes(); + } + + private RateLimitsTrigger createRateLimitsTrigger(Device device) { + return RateLimitsTrigger.builder() + .tenantId(tenantId) + .api(TRANSPORT_MESSAGES_PER_GATEWAY) + .limitLevel(device.getId()) + .limitLevelEntityName(device.getName()) + .build(); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java index e004dd218d..c95259aec5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/CacheConstants.java @@ -48,4 +48,6 @@ public class CacheConstants { public static final String ENTITY_COUNT_CACHE = "entityCount"; public static final String RESOURCE_INFO_CACHE = "resourceInfo"; public static final String ALARM_TYPES_CACHE = "alarmTypes"; + public static final String MOBILE_APP_SETTINGS_CACHE = "mobileAppSettings"; + public static final String MOBILE_SECRET_KEY_CACHE = "mobileSecretKey"; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index e022e56c5b..5b4312f7b0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -31,4 +31,5 @@ public class SystemParams { JsonNode userSettings; long maxDatapointsLimit; long maxResourceSize; + boolean mobileQrEnabled; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/audit/AuditLog.java b/common/data/src/main/java/org/thingsboard/server/common/data/audit/AuditLog.java index bc7ac85755..fdbbe4436c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/audit/AuditLog.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/audit/AuditLog.java @@ -49,7 +49,7 @@ public class AuditLog extends BaseData { private ActionType actionType; @Schema(description = "JsonNode represented action data", accessMode = Schema.AccessMode.READ_ONLY) private JsonNode actionData; - @Schema(description = "String represented Action status", example = "SUCCESS", allowableValues = "SUCCESS,FAILURE", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "String represented Action status", example = "SUCCESS", allowableValues = {"SUCCESS", "FAILURE"}, accessMode = Schema.AccessMode.READ_ONLY) private ActionStatus actionStatus; @Schema(description = "Failure action details info. An empty string in case of action status type 'SUCCESS', otherwise includes stack trace of the caused exception.", accessMode = Schema.AccessMode.READ_ONLY) private String actionFailureDetails; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java index 36179502ad..7ab5ee3b56 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java @@ -39,4 +39,5 @@ public class OtherConfiguration extends PowerSavingConfiguration { private Long pagingTransmissionWindow; private String fwUpdateResource; private String swUpdateResource; + private String defaultObjectIDVer; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java index c810dfdbb2..17c94cc4e1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java @@ -25,7 +25,7 @@ public abstract class DebugEventFilter implements EventFilter { @Schema(description = "String value representing the server name, identifier or ip address where the platform is running", example = "ip-172-31-24-152") protected String server; - @Schema(description = "Boolean value to filter the errors", allowableValues = "false, true") + @Schema(description = "Boolean value to filter the errors", allowableValues = {"false", "true"}) protected boolean isError; @Schema(description = "The case insensitive 'contains' filter based on error message", example = "not present in the DB") protected String errorStr; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java index 643dd0bef2..3c07a699c0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java @@ -27,7 +27,7 @@ public class LifeCycleEventFilter implements EventFilter { protected String server; @Schema(description = "String value representing the lifecycle event type", example = "STARTED") protected String event; - @Schema(description = "String value representing status of the lifecycle event", allowableValues = "Success, Failure") + @Schema(description = "String value representing status of the lifecycle event", allowableValues = {"Success", "Failure"}) protected String status; @Schema(description = "The case insensitive 'contains' filter based on error message", example = "not present in the DB") protected String errorStr; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/RuleNodeDebugEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/RuleNodeDebugEventFilter.java index 6f99de5986..3d43b86a6b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/RuleNodeDebugEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/RuleNodeDebugEventFilter.java @@ -25,7 +25,7 @@ import org.thingsboard.server.common.data.StringUtils; @Schema public class RuleNodeDebugEventFilter extends DebugEventFilter { - @Schema(description = "String value representing msg direction type (incoming to entity or outcoming from entity)", allowableValues = "IN, OUT") + @Schema(description = "String value representing msg direction type (incoming to entity or outcoming from entity)", allowableValues = {"IN", "OUT"}) protected String msgDirectionType; @Schema(description = "String value representing the entity id in the event body (originator of the message)", example = "de9d54a0-2b7a-11ec-a3cc-23386423d98f") protected String entityId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/exception/RateLimitExceededException.java b/common/data/src/main/java/org/thingsboard/server/common/data/exception/RateLimitExceededException.java new file mode 100644 index 0000000000..27be0f65d2 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/exception/RateLimitExceededException.java @@ -0,0 +1,30 @@ +/** + * 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.common.data.exception; + +import org.thingsboard.server.common.data.limit.LimitedApi; + +public class RateLimitExceededException extends AbstractRateLimitException { + + public RateLimitExceededException(String message) { + super(message); + } + + public RateLimitExceededException(LimitedApi api) { + super("Rate limit for " + api.getLabel() + " is exceeded"); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppSettingsId.java new file mode 100644 index 0000000000..d152701fc4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppSettingsId.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.common.data.id; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.UUID; + +@Schema +public class MobileAppSettingsId extends UUIDBased { + + private static final long serialVersionUID = 1L; + + @JsonCreator + public MobileAppSettingsId(@JsonProperty("id") UUID id) { + super(id); + } + + public static MobileAppSettingsId fromString(String mobileAppSettingsId) { + return new MobileAppSettingsId(UUID.fromString(mobileAppSettingsId)); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java b/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java index 1a0f038c90..11702100d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/limit/LimitedApi.java @@ -39,7 +39,9 @@ public enum LimitedApi { TWO_FA_VERIFICATION_CODE_SEND(false, true), TWO_FA_VERIFICATION_CODE_CHECK(false, true), TRANSPORT_MESSAGES_PER_TENANT("transport messages", true), - TRANSPORT_MESSAGES_PER_DEVICE("transport messages per device", false); + TRANSPORT_MESSAGES_PER_DEVICE("transport messages per device", false), + TRANSPORT_MESSAGES_PER_GATEWAY("transport messages per gateway", false), + EMAILS("emails sending", true); private Function configExtractor; @Getter diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/AndroidConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/AndroidConfig.java new file mode 100644 index 0000000000..e1fb5056fc --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/AndroidConfig.java @@ -0,0 +1,38 @@ +/** + * 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.common.data.mobile; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.validation.NoXss; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class AndroidConfig { + + private boolean enabled; + @NoXss + private String appPackage; + @NoXss + private String sha256CertFingerprints; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgePosition.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgePosition.java new file mode 100644 index 0000000000..89c46a5c12 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgePosition.java @@ -0,0 +1,23 @@ +/** + * 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.common.data.mobile; + +public enum BadgePosition { + + RIGHT, + LEFT; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgeStyle.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgeStyle.java new file mode 100644 index 0000000000..51606a2340 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/BadgeStyle.java @@ -0,0 +1,24 @@ +/** + * 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.common.data.mobile; + + +public enum BadgeStyle { + + ORIGINAL, + WHITE; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/IosConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/IosConfig.java new file mode 100644 index 0000000000..f03b446b3b --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/IosConfig.java @@ -0,0 +1,36 @@ +/** + * 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.common.data.mobile; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.validation.NoXss; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class IosConfig { + + private boolean enabled; + @NoXss + private String appId; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java new file mode 100644 index 0000000000..9a7b88f22c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/MobileAppSettings.java @@ -0,0 +1,45 @@ +/** + * 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.common.data.mobile; + +import jakarta.validation.Valid; +import lombok.Data; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.id.MobileAppSettingsId; +import org.thingsboard.server.common.data.id.TenantId; + +@Data +public class MobileAppSettings extends BaseData implements HasTenantId { + + private static final long serialVersionUID = 2628323657987010348L; + + private TenantId tenantId; + private boolean useDefaultApp; + @Valid + private AndroidConfig androidConfig; + @Valid + private IosConfig iosConfig; + @Valid + private QRCodeConfig qrCodeConfig; + + public MobileAppSettings() { + } + public MobileAppSettings(MobileAppSettingsId id) { + super(id); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java new file mode 100644 index 0000000000..b3f975870e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/QRCodeConfig.java @@ -0,0 +1,40 @@ +/** + * 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.common.data.mobile; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.validation.NoXss; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode +public class QRCodeConfig { + + private boolean showOnHomePage; + private boolean badgeEnabled; + private boolean qrCodeLabelEnabled; + private BadgePosition badgePosition; + private BadgeStyle badgeStyle; + @NoXss + private String qrCodeLabel; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentDescriptor.java b/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentDescriptor.java index d58ca2d62d..4275180ede 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentDescriptor.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/plugin/ComponentDescriptor.java @@ -39,7 +39,7 @@ public class ComponentDescriptor extends BaseData { @Getter @Setter private ComponentType type; @Schema(description = "Scope of the Rule Node. Always set to 'TENANT', since no rule chains on the 'SYSTEM' level yet.", accessMode = Schema.AccessMode.READ_ONLY, allowableValues = "TENANT", example = "TENANT") @Getter @Setter private ComponentScope scope; - @Schema(description = "Clustering mode of the RuleNode. This mode represents the ability to start Rule Node in multiple microservices.", accessMode = Schema.AccessMode.READ_ONLY, allowableValues = "USER_PREFERENCE, ENABLED, SINGLETON", example = "ENABLED") + @Schema(description = "Clustering mode of the RuleNode. This mode represents the ability to start Rule Node in multiple microservices.", accessMode = Schema.AccessMode.READ_ONLY, allowableValues = {"USER_PREFERENCE", "ENABLED", "SINGLETON"}, example = "ENABLED") @Getter @Setter private ComponentClusteringMode clusteringMode; @Length(fieldName = "name") @Schema(description = "Name of the Rule Node. Taken from the @RuleNode annotation.", accessMode = Schema.AccessMode.READ_ONLY, example = "Custom Rule Node") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/queue/Queue.java b/common/data/src/main/java/org/thingsboard/server/common/data/queue/Queue.java index e3c6836aab..1c08072e61 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/queue/Queue.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/queue/Queue.java @@ -30,7 +30,7 @@ import org.thingsboard.server.common.data.validation.NoXss; import java.util.Optional; @Data -public class Queue extends BaseDataWithAdditionalInfo implements HasName, HasTenantId { +public class Queue extends BaseDataWithAdditionalInfo implements HasName, HasTenantId, QueueConfig { private TenantId tenantId; @NoXss @Length(fieldName = "name") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueConfig.java new file mode 100644 index 0000000000..383c613a38 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/queue/QueueConfig.java @@ -0,0 +1,24 @@ +/** + * 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.common.data.queue; + +public interface QueueConfig { + + boolean isConsumerPerPartition(); + + int getPollInterval(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java index 24d8cc1175..e269f0438d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/DeviceCredentials.java @@ -70,7 +70,7 @@ public class DeviceCredentials extends BaseData implements this.deviceId = deviceId; } - @Schema(description = "Type of the credentials", allowableValues ="ACCESS_TOKEN, X509_CERTIFICATE, MQTT_BASIC, LWM2M_CREDENTIALS") + @Schema(description = "Type of the credentials", allowableValues = {"ACCESS_TOKEN", "X509_CERTIFICATE", "MQTT_BASIC", "LWM2M_CREDENTIALS"}) @Override public DeviceCredentialsType getCredentialsType() { return credentialsType; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java index 459aa7b26f..ae484136ce 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/JwtPair.java @@ -20,10 +20,12 @@ import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.security.Authority; +import java.io.Serializable; + @Schema(description = "JWT Pair") @Data @NoArgsConstructor -public class JwtPair { +public class JwtPair implements Serializable { @Schema(description = "The JWT Access Token. Used to perform API calls.", example = "AAB254FF67D..") private String token; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java index 07262c7eb4..aa4d303440 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/SecuritySettings.java @@ -32,4 +32,6 @@ public class SecuritySettings implements Serializable { private Integer maxFailedLoginAttempts; @Schema(description = "Email to use for notifications about locked users." ) private String userLockoutNotificationEmail; + @Schema(description = "Mobile secret key length" ) + private Integer mobileSecretKeyLength; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java index 5fc0c14d61..549770c539 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java @@ -89,7 +89,7 @@ public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { @Schema(description = "Address range", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String addressRange; - @Schema(allowableValues = "0-10,13-14", + @Schema(allowableValues = {"0-10" ,"13-14"}, description = "0 - SMSC Default Alphabet (ASCII for short and long code and to GSM for toll-free, used as default)\n" + "1 - IA5 (ASCII for short and long code, Latin 9 for toll-free (ISO-8859-9))\n" + "2 - Octet Unspecified (8-bit binary)\n" + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index a0cfbfe1e1..d990c300da 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -47,6 +47,9 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura private String transportDeviceMsgRateLimit; private String transportDeviceTelemetryMsgRateLimit; private String transportDeviceTelemetryDataPointsRateLimit; + private String transportGatewayMsgRateLimit; + private String transportGatewayTelemetryMsgRateLimit; + private String transportGatewayTelemetryDataPointsRateLimit; private String tenantEntityExportRateLimit; private String tenantEntityImportRateLimit; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/TbPair.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/TbPair.java index bdca8b2e77..6e8b2e5696 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/util/TbPair.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/TbPair.java @@ -23,4 +23,8 @@ import lombok.Data; public class TbPair { private S first; private T second; + + public static TbPair of(S first, T second) { + return new TbPair<>(first, second); + } } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java index c823ea4d73..f3a0e47d09 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/queue/ServiceType.java @@ -15,11 +15,23 @@ */ package org.thingsboard.server.common.msg.queue; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Getter public enum ServiceType { - TB_CORE, TB_RULE_ENGINE, TB_TRANSPORT, JS_EXECUTOR, TB_VC_EXECUTOR; + TB_CORE("TB Core"), + TB_RULE_ENGINE("TB Rule Engine"), + TB_TRANSPORT("TB Transport"), + JS_EXECUTOR("JS Executor"), + TB_VC_EXECUTOR("TB VC Executor"); + + private final String label; public static ServiceType of(String serviceType) { return ServiceType.valueOf(serviceType.replace("-", "_").toUpperCase()); } + } diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 7b2aa2fe1b..53a3026424 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -96,6 +96,8 @@ message SessionInfoProto { int64 deviceProfileIdLSB = 13; int64 customerIdMSB = 14; int64 customerIdLSB = 15; + optional int64 gatewayIdMSB = 16; + optional int64 gatewayIdLSB = 17; } enum SessionEvent { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/consumer/QueueConsumerManager.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java similarity index 98% rename from application/src/main/java/org/thingsboard/server/service/queue/consumer/QueueConsumerManager.java rename to common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java index e7b3a2f2b7..90f4cf050c 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/consumer/QueueConsumerManager.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/consumer/QueueConsumerManager.java @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.service.queue.consumer; +package org.thingsboard.server.queue.common.consumer; import lombok.Builder; import lombok.Getter; @@ -106,4 +106,5 @@ public class QueueConsumerManager { public interface MsgPackProcessor { void process(List msgs, TbQueueConsumer consumer) throws Exception; } -} \ No newline at end of file + +} diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java index 9c8f63594b..0cb1194167 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaAdmin.java @@ -17,7 +17,6 @@ package org.thingsboard.server.queue.kafka; import lombok.Getter; import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.admin.CreateTopicsResult; import org.apache.kafka.clients.admin.ListConsumerGroupOffsetsResult; import org.apache.kafka.clients.admin.NewTopic; @@ -41,24 +40,17 @@ import java.util.concurrent.TimeoutException; @Slf4j public class TbKafkaAdmin implements TbQueueAdmin { - @Getter - private final AdminClient client; + private final TbKafkaSettings settings; private final Map topicConfigs; - private final Set topics = ConcurrentHashMap.newKeySet(); private final int numPartitions; + private volatile Set topics; private final short replicationFactor; public TbKafkaAdmin(TbKafkaSettings settings, Map topicConfigs) { - client = AdminClient.create(settings.toAdminProps()); + this.settings = settings; this.topicConfigs = topicConfigs; - try { - topics.addAll(client.listTopics().names().get()); - } catch (InterruptedException | ExecutionException e) { - log.error("Failed to get all topics.", e); - } - String numPartitionsStr = topicConfigs.get(TbKafkaTopicConfigs.NUM_PARTITIONS_SETTING); if (numPartitionsStr != null) { numPartitions = Integer.parseInt(numPartitionsStr); @@ -71,6 +63,7 @@ public class TbKafkaAdmin implements TbQueueAdmin { @Override public void createTopicIfNotExists(String topic, String properties) { + Set topics = getTopics(); if (topics.contains(topic)) { return; } @@ -93,12 +86,13 @@ public class TbKafkaAdmin implements TbQueueAdmin { @Override public void deleteTopic(String topic) { + Set topics = getTopics(); if (topics.contains(topic)) { - client.deleteTopics(Collections.singletonList(topic)); + settings.getAdminClient().deleteTopics(Collections.singletonList(topic)); } else { try { - if (client.listTopics().names().get().contains(topic)) { - client.deleteTopics(Collections.singletonList(topic)); + if (settings.getAdminClient().listTopics().names().get().contains(topic)) { + settings.getAdminClient().deleteTopics(Collections.singletonList(topic)); } else { log.warn("Kafka topic [{}] does not exist.", topic); } @@ -108,15 +102,28 @@ public class TbKafkaAdmin implements TbQueueAdmin { } } - @Override - public void destroy() { - if (client != null) { - client.close(); + private Set getTopics() { + if (topics == null) { + synchronized (this) { + if (topics == null) { + topics = ConcurrentHashMap.newKeySet(); + try { + topics.addAll(settings.getAdminClient().listTopics().names().get()); + } catch (InterruptedException | ExecutionException e) { + log.error("Failed to get all topics.", e); + } + } + } } + return topics; } public CreateTopicsResult createTopic(NewTopic topic) { - return client.createTopics(Collections.singletonList(topic)); + return settings.getAdminClient().createTopics(Collections.singletonList(topic)); + } + + @Override + public void destroy() { } /** @@ -138,7 +145,7 @@ public class TbKafkaAdmin implements TbQueueAdmin { return; } Map oldOffsets = - client.listConsumerGroupOffsets(fatGroupId).partitionsToOffsetAndMetadata().get(10, TimeUnit.SECONDS); + settings.getAdminClient().listConsumerGroupOffsets(fatGroupId).partitionsToOffsetAndMetadata().get(10, TimeUnit.SECONDS); if (oldOffsets.isEmpty()) { return; } @@ -150,7 +157,7 @@ public class TbKafkaAdmin implements TbQueueAdmin { } var om = consumerOffset.getValue(); Map newOffsets = - client.listConsumerGroupOffsets(newGroupId).partitionsToOffsetAndMetadata().get(10, TimeUnit.SECONDS); + settings.getAdminClient().listConsumerGroupOffsets(newGroupId).partitionsToOffsetAndMetadata().get(10, TimeUnit.SECONDS); var existingOffset = newOffsets.get(tp); if (existingOffset == null) { @@ -161,7 +168,7 @@ public class TbKafkaAdmin implements TbQueueAdmin { } else { log.info("[{}] SHOULD alter topic offset [{}] less than old node group offset [{}]", tp, existingOffset.offset(), om.offset()); } - client.alterConsumerGroupOffsets(newGroupId, Map.of(tp, om)).all().get(10, TimeUnit.SECONDS); + settings.getAdminClient().alterConsumerGroupOffsets(newGroupId, Map.of(tp, om)).all().get(10, TimeUnit.SECONDS); log.info("[{}] altered new consumer groupId {}", tp, newGroupId); break; } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java index dbc1b7b7c4..56440cf4a5 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerStatsService.java @@ -15,11 +15,12 @@ */ package org.thingsboard.server.queue.kafka; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Builder; import lombok.Data; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.consumer.Consumer; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.consumer.KafkaConsumer; @@ -35,8 +36,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.queue.discovery.PartitionService; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -62,7 +61,6 @@ public class TbKafkaConsumerStatsService { @Autowired private PartitionService partitionService; - private AdminClient adminClient; private Consumer consumer; private ScheduledExecutorService statsPrintScheduler; @@ -71,7 +69,6 @@ public class TbKafkaConsumerStatsService { if (!statsConfig.getEnabled()) { return; } - this.adminClient = AdminClient.create(kafkaSettings.toAdminProps()); this.statsPrintScheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("kafka-consumer-stats")); Properties consumerProps = kafkaSettings.toConsumerProps(null); @@ -90,7 +87,7 @@ public class TbKafkaConsumerStatsService { } for (String groupId : monitoredGroups) { try { - Map groupOffsets = adminClient.listConsumerGroupOffsets(groupId).partitionsToOffsetAndMetadata() + Map groupOffsets = kafkaSettings.getAdminClient().listConsumerGroupOffsets(groupId).partitionsToOffsetAndMetadata() .get(statsConfig.getKafkaResponseTimeoutMs(), TimeUnit.MILLISECONDS); Map endOffsets = consumer.endOffsets(groupOffsets.keySet(), timeoutDuration); @@ -157,9 +154,6 @@ public class TbKafkaConsumerStatsService { if (statsPrintScheduler != null) { statsPrintScheduler.shutdownNow(); } - if (adminClient != null) { - adminClient.close(); - } if (consumer != null) { consumer.close(); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java index 96fa7afb7f..760487c61e 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaSettings.java @@ -19,6 +19,7 @@ import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.clients.admin.AdminClient; import org.apache.kafka.clients.admin.AdminClientConfig; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.clients.producer.ProducerConfig; @@ -34,6 +35,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.TbProperty; import org.thingsboard.server.queue.util.PropertyUtils; +import javax.annotation.PreDestroy; import java.util.Collections; import java.util.List; import java.util.Map; @@ -143,13 +145,7 @@ public class TbKafkaSettings { @Setter private Map> consumerPropertiesPerTopic = Collections.emptyMap(); - public Properties toAdminProps() { - Properties props = toProps(); - props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, servers); - props.put(AdminClientConfig.RETRIES_CONFIG, retries); - - return props; - } + private volatile AdminClient adminClient; public Properties toConsumerProps(String topic) { Properties props = toProps(); @@ -221,4 +217,29 @@ public class TbKafkaSettings { } } + public AdminClient getAdminClient() { + if (adminClient == null) { + synchronized (this) { + if (adminClient == null) { + adminClient = AdminClient.create(toAdminProps()); + } + } + } + return adminClient; + } + + protected Properties toAdminProps() { + Properties props = toProps(); + props.put(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG, servers); + props.put(AdminClientConfig.RETRIES_CONFIG, retries); + return props; + } + + @PreDestroy + private void destroy() { + if (adminClient != null) { + adminClient.close(); + } + } + } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java index 30dac37851..9728642b8d 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaMonolithQueueFactory.java @@ -228,7 +228,7 @@ public class KafkaMonolithQueueFactory implements TbCoreQueueFactory, TbRuleEngi TbKafkaConsumerTemplate.TbKafkaConsumerTemplateBuilder> consumerBuilder = TbKafkaConsumerTemplate.builder(); consumerBuilder.settings(kafkaSettings); consumerBuilder.topic(topicService.buildTopicName(coreSettings.getTopic())); - consumerBuilder.clientId("monolith-core-consumer-" + serviceInfoProvider.getServiceId()); + consumerBuilder.clientId("monolith-core-consumer-" + serviceInfoProvider.getServiceId() + "-" + consumerCount.incrementAndGet()); consumerBuilder.groupId(topicService.buildTopicName("monolith-core-consumer")); consumerBuilder.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), ToCoreMsg.parseFrom(msg.getData()), msg.getHeaders())); consumerBuilder.admin(coreAdmin); diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java index 95fea30d7b..ddb2be8f2a 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/provider/KafkaTbCoreQueueFactory.java @@ -56,6 +56,7 @@ import org.thingsboard.server.queue.settings.TbQueueVersionControlSettings; import jakarta.annotation.PreDestroy; import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicLong; @Component @ConditionalOnExpression("'${queue.type:null}'=='kafka' && '${service.type:null}'=='tb-core'") @@ -84,6 +85,8 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { private final TbQueueAdmin housekeeperAdmin; private final TbQueueAdmin housekeeperReprocessingAdmin; + private final AtomicLong consumerCount = new AtomicLong(); + public KafkaTbCoreQueueFactory(TopicService topicService, TbKafkaSettings kafkaSettings, TbServiceInfoProvider serviceInfoProvider, @@ -174,7 +177,7 @@ public class KafkaTbCoreQueueFactory implements TbCoreQueueFactory { TbKafkaConsumerTemplate.TbKafkaConsumerTemplateBuilder> consumerBuilder = TbKafkaConsumerTemplate.builder(); consumerBuilder.settings(kafkaSettings); consumerBuilder.topic(topicService.buildTopicName(coreSettings.getTopic())); - consumerBuilder.clientId("tb-core-consumer-" + serviceInfoProvider.getServiceId()); + consumerBuilder.clientId("tb-core-consumer-" + serviceInfoProvider.getServiceId() + "-" + consumerCount.incrementAndGet()); consumerBuilder.groupId(topicService.buildTopicName("tb-core-node")); consumerBuilder.decoder(msg -> new TbProtoQueueMsg<>(msg.getKey(), ToCoreMsg.parseFrom(msg.getData()), msg.getHeaders())); consumerBuilder.admin(coreAdmin); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerListener.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerListener.java index b59914e2ac..7797242b51 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerListener.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerListener.java @@ -29,6 +29,7 @@ import org.eclipse.leshan.server.registration.Registration; import org.eclipse.leshan.server.registration.RegistrationListener; import org.eclipse.leshan.server.registration.RegistrationUpdate; import org.eclipse.leshan.server.send.SendListener; +import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClient; import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; import java.util.Collection; @@ -101,7 +102,10 @@ public class LwM2mServerListener { @Override public void onResponse(SingleObservation observation, Registration registration, ObserveResponse response) { if (registration != null) { - service.onUpdateValueAfterReadResponse(registration, convertObjectIdToVersionedId(observation.getPath().toString(), registration), response); + LwM2mClient lwM2MClient = service.getClientContext().getClientByEndpoint(registration.getEndpoint()); + if (lwM2MClient != null) { + service.onUpdateValueAfterReadResponse(registration, convertObjectIdToVersionedId(observation.getPath().toString(), lwM2MClient), response); + } } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java index 3e407bdfc9..78f76adc1d 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mVersionedModelProvider.java @@ -27,10 +27,10 @@ import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.queue.util.TbLwM2mTransportComponent; +import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClient; import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClientContext; import java.util.ArrayList; -import java.util.Base64; import java.util.Collection; import java.util.Map; import java.util.Optional; @@ -107,7 +107,8 @@ public class LwM2mVersionedModelProvider implements LwM2mModelProvider { @Override public ObjectModel getObjectModel(int objectId) { - String version = String.valueOf(registration.getSupportedVersion(objectId)); + LwM2mClient lwM2mClient = lwM2mClientContext.getClientByEndpoint(registration.getEndpoint()); + String version = lwM2mClient.getSupportedObjectVersion(objectId).toString(); if (version != null) { return this.getObjectModelDynamic(objectId, version); } @@ -116,7 +117,8 @@ public class LwM2mVersionedModelProvider implements LwM2mModelProvider { @Override public Collection getObjectModels() { - Map supportedObjects = this.registration.getSupportedObject(); + LwM2mClient lwM2mClient = lwM2mClientContext.getClientByEndpoint(registration.getEndpoint()); + Map supportedObjects = lwM2mClient.getSupportedClientObjects(); Collection result = new ArrayList<>(supportedObjects.size()); for (Map.Entry supportedObject : supportedObjects.entrySet()) { ObjectModel objectModel = this.getObjectModelDynamic(supportedObject.getKey(), String.valueOf(supportedObject.getValue())); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java index cb391b27e9..141aa83b7e 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java @@ -21,6 +21,8 @@ import lombok.Setter; import lombok.ToString; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.LwM2m; +import org.eclipse.leshan.core.LwM2m.Version; +import org.eclipse.leshan.core.link.Link; import org.eclipse.leshan.core.link.attributes.Attribute; import org.eclipse.leshan.core.model.ObjectModel; import org.eclipse.leshan.core.model.ResourceModel; @@ -37,6 +39,7 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.device.data.Lwm2mDeviceTransportConfiguration; import org.thingsboard.server.common.data.device.data.PowerMode; +import org.thingsboard.server.common.data.device.profile.Lwm2mDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.transport.auth.ValidateDeviceCredentialsResponse; import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; @@ -61,8 +64,8 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_PATH; +import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.LWM2M_OBJECT_VERSION_DEFAULT; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.convertMultiResourceValuesFromRpcBody; -import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.convertObjectIdToVersionedId; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.equalsResourceTypeGetSimpleName; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.fromVersionedIdToObjectId; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.getVerFromPathIdVerOrId; @@ -129,6 +132,12 @@ public class LwM2mClient { @Setter private UUID lastSentRpcId; + @Setter + private LwM2m.Version defaultObjectIDVer; + + @Getter + private Map supportedClientObjects; + public Object clone() throws CloneNotSupportedException { return super.clone(); } @@ -142,6 +151,7 @@ public class LwM2mClient { this.state = LwM2MClientState.CREATED; this.lock = new ReentrantLock(); this.retryAttempts = new AtomicInteger(0); + this.supportedClientObjects = null; } public void init(ValidateDeviceCredentialsResponse credentials, UUID sessionId) { @@ -153,12 +163,14 @@ public class LwM2mClient { this.edrxCycle = credentials.getDeviceInfo().getEdrxCycle(); this.psmActivityTimer = credentials.getDeviceInfo().getPsmActivityTimer(); this.pagingTransmissionWindow = credentials.getDeviceInfo().getPagingTransmissionWindow(); + this.defaultObjectIDVer = getObjectIDVerFromDeviceProfile(credentials.getDeviceProfile()); } public void setRegistration(Registration registration) { this.registration = registration; this.clientSupportContentFormats = clientSupportContentFormat(registration); this.defaultContentFormat = calculateDefaultContentFormat(registration); + this.setSupportedClientObjects(); } public void lock() { @@ -197,6 +209,18 @@ public class LwM2mClient { builder.setDeviceType(deviceProfile.getName()); } + private LwM2m.Version getObjectIDVerFromDeviceProfile(DeviceProfile deviceProfile) { + String defaultObjectIdVer = null; + if (deviceProfile != null) { + defaultObjectIdVer = ((Lwm2mDeviceProfileTransportConfiguration) deviceProfile + .getProfileData() + .getTransportConfiguration()) + .getClientLwM2mSettings() + .getDefaultObjectIDVer(); + } + return new Version(defaultObjectIdVer == null ? LWM2M_OBJECT_VERSION_DEFAULT : defaultObjectIdVer); + } + public void refreshSessionId(String nodeId) { UUID newId = UUID.randomUUID(); SessionInfoProto.Builder builder = SessionInfoProto.newBuilder().mergeFrom(session); @@ -240,67 +264,28 @@ public class LwM2mClient { } } - public Object getResourceValue(String pathRezIdVer, String pathRezId) { - String pathRez = pathRezIdVer == null ? convertObjectIdToVersionedId(pathRezId, this.registration) : pathRezIdVer; - if (this.resources.get(pathRez) != null) { - return this.resources.get(pathRez).getLwM2mResource().getValue(); - } - return null; - } - - public Object getResourceNameByRezId(String pathRezIdVer, String pathRezId) { - String pathRez = pathRezIdVer == null ? convertObjectIdToVersionedId(pathRezId, this.registration) : pathRezIdVer; - if (this.resources.get(pathRez) != null) { - return this.resources.get(pathRez).getResourceModel().name; - } - return null; - } - - public String getRezIdByResourceNameAndObjectInstanceId(String resourceName, String pathObjectInstanceIdVer, LwM2mModelProvider modelProvider) { - LwM2mPath pathIds = getLwM2mPathFromString(pathObjectInstanceIdVer); - if (pathIds.isObjectInstance()) { - Set rezIds = modelProvider.getObjectModel(registration) - .getObjectModel(pathIds.getObjectId()).resources.entrySet() - .stream() - .filter(map -> resourceName.equals(map.getValue().name)) - .map(map -> map.getKey()) - .collect(Collectors.toSet()); - return rezIds.size() > 0 ? String.valueOf(rezIds.stream().findFirst().get()) : null; - } - return null; - } - public ResourceModel getResourceModel(String pathIdVer, LwM2mModelProvider modelProvider) { LwM2mPath pathIds = getLwM2mPathFromString(pathIdVer); - String verSupportedObject = String.valueOf(registration.getSupportedObject().get(pathIds.getObjectId())); + String verSupportedObject = String.valueOf(this.getSupportedObjectVersion(pathIds.getObjectId())); String verRez = getVerFromPathIdVerOrId(pathIdVer); return verRez != null && verRez.equals(verSupportedObject) ? modelProvider.getObjectModel(registration) .getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()) : null; } - public boolean isResourceMultiInstances(String pathIdVer, LwM2mModelProvider modelProvider) { - var resourceModel = getResourceModel(pathIdVer, modelProvider); - if (resourceModel != null && resourceModel.multiple != null) { - return resourceModel.multiple; - } else { - return false; - } - } - public ObjectModel getObjectModel(String pathIdVer, LwM2mModelProvider modelProvider) { try { LwM2mPath pathIds = getLwM2mPathFromString(pathIdVer); - String verSupportedObject = String.valueOf(registration.getSupportedObject().get(pathIds.getObjectId())); + String verSupportedObject = String.valueOf(this.getSupportedObjectVersion(pathIds.getObjectId())); String verRez = getVerFromPathIdVerOrId(pathIdVer); return verRez != null && verRez.equals(verSupportedObject) ? modelProvider.getObjectModel(registration) .getObjectModel(pathIds.getObjectId()) : null; } catch (Exception e) { if (registration == null) { log.error("[{}] Failed Registration is null, GetObjectModelRegistration. ", this.endpoint, e); - } else if (registration.getSupportedObject() == null) { + } else if (this.getSupportedClientObjects() == null) { log.error("[{}] Failed SupportedObject in Registration, GetObjectModelRegistration.", this.endpoint, e); } else { - log.error("[{}] Failed ModelProvider.getObjectModel [{}] in Registration. ", this.endpoint, registration.getSupportedObject(), e); + log.error("[{}] Failed ModelProvider.getObjectModel [{}] in Registration. ", this.endpoint, this.getSupportedClientObjects(), e); } return null; } @@ -371,7 +356,7 @@ public class LwM2mClient { public String isValidObjectVersion(String path) { LwM2mPath pathIds = getLwM2mPathFromString(path); - LwM2m.Version verSupportedObject = registration.getSupportedObject().get(pathIds.getObjectId()); + LwM2m.Version verSupportedObject = this.getSupportedObjectVersion(pathIds.getObjectId()); if (verSupportedObject == null) { return String.format("Specified object id %s absent in the list supported objects of the client or is security object!", pathIds.getObjectId()); } else { @@ -460,5 +445,31 @@ public class LwM2mClient { return new LwM2mPath(fromVersionedIdToObjectId(path)); } + public LwM2m.Version getDefaultObjectIDVer() { + return this.defaultObjectIDVer == null ? new Version(LWM2M_OBJECT_VERSION_DEFAULT) : this.defaultObjectIDVer; + } + + public LwM2m.Version getSupportedObjectVersion(Integer objectid) { + return this.supportedClientObjects.get(objectid); + } + + private void setSupportedClientObjects(){ + this.supportedClientObjects = new ConcurrentHashMap<>(); + for (Link link: this.registration.getSortedObjectLinks()) { + LwM2mPath lwM2mPath = new LwM2mPath(link.getUriReference()); + if (lwM2mPath.isObject()) { + LwM2m.Version ver; + if (link.getAttributes().get("ver")!= null) { + ver = new LwM2m.Version(link.getAttributes().get("ver").getValue().toString()); + } else { + ver = getDefaultObjectIDVer(); + } + this.supportedClientObjects.put(lwM2mPath.getObjectId(), ver); + } else if (lwM2mPath.getObjectId() != null && this.supportedClientObjects.get(lwM2mPath.getObjectId()) == null){ + this.supportedClientObjects.put(lwM2mPath.getObjectId(), getDefaultObjectIDVer()); + } + } + } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java index 4f35489250..851b517db6 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java @@ -396,7 +396,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { Arrays.stream(client.getRegistration().getObjectLinks()).forEach(link -> { LwM2mPath pathIds = new LwM2mPath(link.getUriReference()); if (!pathIds.isRoot()) { - clientObjects.add(convertObjectIdToVersionedId(link.getUriReference(), client.getRegistration())); + clientObjects.add(convertObjectIdToVersionedId(link.getUriReference(), client)); } }); return (clientObjects.size() > 0) ? clientObjects : null; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java index f9d23383cd..0b5e995ea5 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/ota/DefaultLwM2MOtaUpdateService.java @@ -481,7 +481,7 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl } private void startUpdateUsingUrl(LwM2mClient client, String id, String url) { - String targetIdVer = convertObjectIdToVersionedId(id, client.getRegistration()); + String targetIdVer = convertObjectIdToVersionedId(id, client); TbLwM2MWriteReplaceRequest request = TbLwM2MWriteReplaceRequest.builder().versionedId(targetIdVer).value(url).timeout(clientContext.getRequestTimeout(client)).build(); downlinkHandler.sendWriteReplaceRequest(client, request, new TbLwM2MWriteResponseCallback(uplinkHandler, logService, client, targetIdVer)); } @@ -512,10 +512,10 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl } switch (strategy) { case OBJ_5_BINARY: - startUpdateUsingBinary(client, convertObjectIdToVersionedId(FW_PACKAGE_5_ID, client.getRegistration()), otaPackageId); + startUpdateUsingBinary(client, convertObjectIdToVersionedId(FW_PACKAGE_5_ID, client), otaPackageId); break; case OBJ_19_BINARY: - startUpdateUsingBinary(client, convertObjectIdToVersionedId(FW_PACKAGE_19_ID, client.getRegistration()), otaPackageId); + startUpdateUsingBinary(client, convertObjectIdToVersionedId(FW_PACKAGE_19_ID, client), otaPackageId); break; case OBJ_5_TEMP_URL: startUpdateUsingUrl(client, FW_URL_ID, info.getBaseUrl() + "/" + FIRMWARE_UPDATE_COAP_RESOURCE + "/" + otaPackageId.toString()); @@ -534,7 +534,7 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl LwM2MSoftwareUpdateStrategy strategy = info.getStrategy(); switch (strategy) { case BINARY: - startUpdateUsingBinary(client, convertObjectIdToVersionedId(SW_PACKAGE_ID, client.getRegistration()), otaPackageId); + startUpdateUsingBinary(client, convertObjectIdToVersionedId(SW_PACKAGE_ID, client), otaPackageId); break; case TEMP_URL: startUpdateUsingUrl(client, SW_PACKAGE_URI_ID, info.getBaseUrl() + "/" + FIRMWARE_UPDATE_COAP_RESOURCE + "/" + otaPackageId.toString()); @@ -566,19 +566,19 @@ public class DefaultLwM2MOtaUpdateService extends LwM2MExecutorAwareService impl } private void executeFwUpdate(LwM2mClient client) { - String fwExecuteVerId = convertObjectIdToVersionedId(FW_EXECUTE_ID, client.getRegistration()); + String fwExecuteVerId = convertObjectIdToVersionedId(FW_EXECUTE_ID, client); TbLwM2MExecuteRequest request = TbLwM2MExecuteRequest.builder().versionedId(fwExecuteVerId).timeout(clientContext.getRequestTimeout(client)).build(); downlinkHandler.sendExecuteRequest(client, request, new TbLwM2MExecuteCallback(logService, client, fwExecuteVerId)); } private void executeSwInstall(LwM2mClient client) { - String swInstallVerId = convertObjectIdToVersionedId(SW_INSTALL_ID, client.getRegistration()); + String swInstallVerId = convertObjectIdToVersionedId(SW_INSTALL_ID, client); TbLwM2MExecuteRequest request = TbLwM2MExecuteRequest.builder().versionedId(swInstallVerId).timeout(clientContext.getRequestTimeout(client)).build(); downlinkHandler.sendExecuteRequest(client, request, new TbLwM2MExecuteCallback(logService, client, swInstallVerId)); } private void executeSwUninstallForUpdate(LwM2mClient client) { - String swInInstallVerId = convertObjectIdToVersionedId(SW_UN_INSTALL_ID, client.getRegistration()); + String swInInstallVerId = convertObjectIdToVersionedId(SW_UN_INSTALL_ID, client); TbLwM2MExecuteRequest request = TbLwM2MExecuteRequest.builder().versionedId(swInInstallVerId).params("1").timeout(clientContext.getRequestTimeout(client)).build(); downlinkHandler.sendExecuteRequest(client, request, new TbLwM2MExecuteCallback(logService, client, swInInstallVerId)); } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/util/LwM2MClientSerDes.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/util/LwM2MClientSerDes.java index 17fef8acb0..8cafc2bfd0 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/util/LwM2MClientSerDes.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/util/LwM2MClientSerDes.java @@ -23,6 +23,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.protobuf.util.JsonFormat; import lombok.SneakyThrows; +import org.eclipse.leshan.core.LwM2m.Version; import org.eclipse.leshan.core.model.ResourceModel; import org.eclipse.leshan.core.node.LwM2mMultipleResource; import org.eclipse.leshan.core.node.LwM2mNodeException; @@ -107,6 +108,8 @@ public class LwM2MClientSerDes { if (client.getPagingTransmissionWindow() != null) { o.addProperty("pagingTransmissionWindow", client.getPagingTransmissionWindow()); } + o.addProperty("defaultObjectIDVer", client.getDefaultObjectIDVer().toString()); + if (client.getRegistration() != null) { String registrationAddress = client.getRegistration().getAddress().toString(); JsonNode registrationNode = registrationSerDes.jSerialize(client.getRegistration()); @@ -339,6 +342,13 @@ public class LwM2MClientSerDes { pagingTransmissionWindowField.set(lwM2mClient, pagingTransmissionWindow.getAsLong()); } + JsonElement defaultObjectIDVer = o.get("defaultObjectIDVer"); + if (defaultObjectIDVer != null) { + Field defaultObjectIDVerField = lwM2mClientClass.getDeclaredField("defaultObjectIDVer"); + defaultObjectIDVerField.setAccessible(true); + defaultObjectIDVerField.set(lwM2mClient, new Version(defaultObjectIDVer.getAsString())); + } + JsonElement registration = o.get("registration"); if (registration != null) { lwM2mClient.setRegistration(registrationSerDes.deserialize(toJsonNode(registration.getAsString()))); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java index fc95bb3728..bfc9f32dd8 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/DefaultLwM2mUplinkMsgHandler.java @@ -368,7 +368,7 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl LwM2mPath path = entry.getKey(); LwM2mNode node = entry.getValue(); LwM2mClient lwM2MClient = clientContext.getClientByEndpoint(registration.getEndpoint()); - String stringPath = convertObjectIdToVersionedId(path.toString(), registration); + String stringPath = convertObjectIdToVersionedId(path.toString(), lwM2MClient); ObjectModel objectModelVersion = lwM2MClient.getObjectModel(stringPath, modelProvider); if (objectModelVersion != null) { if (node instanceof LwM2mObject) { @@ -584,27 +584,27 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl private void updateResourcesValue(LwM2mClient lwM2MClient, LwM2mResource lwM2mResource, String path, Mode mode, int code) { Registration registration = lwM2MClient.getRegistration(); if (lwM2MClient.saveResourceValue(path, lwM2mResource, modelProvider, mode)) { - if (path.equals(convertObjectIdToVersionedId(FW_NAME_ID, registration))) { + if (path.equals(convertObjectIdToVersionedId(FW_NAME_ID, lwM2MClient))) { otaService.onCurrentFirmwareNameUpdate(lwM2MClient, (String) lwM2mResource.getValue()); - } else if (path.equals(convertObjectIdToVersionedId(FW_3_VER_ID, registration))) { + } else if (path.equals(convertObjectIdToVersionedId(FW_3_VER_ID, lwM2MClient))) { otaService.onCurrentFirmwareVersion3Update(lwM2MClient, (String) lwM2mResource.getValue()); - } else if (path.equals(convertObjectIdToVersionedId(FW_VER_ID, registration))) { + } else if (path.equals(convertObjectIdToVersionedId(FW_VER_ID, lwM2MClient))) { otaService.onCurrentFirmwareVersionUpdate(lwM2MClient, (String) lwM2mResource.getValue()); - } else if (path.equals(convertObjectIdToVersionedId(FW_STATE_ID, registration))) { + } else if (path.equals(convertObjectIdToVersionedId(FW_STATE_ID, lwM2MClient))) { otaService.onCurrentFirmwareStateUpdate(lwM2MClient, (Long) lwM2mResource.getValue()); - } else if (path.equals(convertObjectIdToVersionedId(FW_RESULT_ID, registration))) { + } else if (path.equals(convertObjectIdToVersionedId(FW_RESULT_ID, lwM2MClient))) { otaService.onCurrentFirmwareResultUpdate(lwM2MClient, (Long) lwM2mResource.getValue()); - } else if (path.equals(convertObjectIdToVersionedId(FW_DELIVERY_METHOD, registration))) { + } else if (path.equals(convertObjectIdToVersionedId(FW_DELIVERY_METHOD, lwM2MClient))) { otaService.onCurrentFirmwareDeliveryMethodUpdate(lwM2MClient, (Long) lwM2mResource.getValue()); - } else if (path.equals(convertObjectIdToVersionedId(SW_NAME_ID, registration))) { + } else if (path.equals(convertObjectIdToVersionedId(SW_NAME_ID, lwM2MClient))) { otaService.onCurrentSoftwareNameUpdate(lwM2MClient, (String) lwM2mResource.getValue()); - } else if (path.equals(convertObjectIdToVersionedId(SW_VER_ID, registration))) { + } else if (path.equals(convertObjectIdToVersionedId(SW_VER_ID, lwM2MClient))) { otaService.onCurrentSoftwareVersionUpdate(lwM2MClient, (String) lwM2mResource.getValue()); - } else if (path.equals(convertObjectIdToVersionedId(SW_3_VER_ID, registration))) { + } else if (path.equals(convertObjectIdToVersionedId(SW_3_VER_ID, lwM2MClient))) { otaService.onCurrentSoftwareVersion3Update(lwM2MClient, (String) lwM2mResource.getValue()); - } else if (path.equals(convertObjectIdToVersionedId(SW_STATE_ID, registration))) { + } else if (path.equals(convertObjectIdToVersionedId(SW_STATE_ID, lwM2MClient))) { otaService.onCurrentSoftwareStateUpdate(lwM2MClient, (Long) lwM2mResource.getValue()); - } else if (path.equals(convertObjectIdToVersionedId(SW_RESULT_ID, registration))) { + } else if (path.equals(convertObjectIdToVersionedId(SW_RESULT_ID, lwM2MClient))) { otaService.onCurrentSoftwareResultUpdate(lwM2MClient, (Long) lwM2mResource.getValue()); } if (ResponseCode.BAD_REQUEST.getCode() > code) { @@ -969,6 +969,10 @@ public class DefaultLwM2mUplinkMsgHandler extends LwM2MExecutorAwareService impl } } + public LwM2mClientContext getClientContext(){ + return this.clientContext; + }; + private Map getNamesFromProfileForSharedAttributes(LwM2mClient lwM2MClient) { Lwm2mDeviceProfileTransportConfiguration profile = clientContext.getProfile(lwM2MClient.getProfileId()); return profile.getObserveAttr().getKeyName(); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/LwM2mUplinkMsgHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/LwM2mUplinkMsgHandler.java index 65c7fb9654..0896963be2 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/LwM2mUplinkMsgHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/uplink/LwM2mUplinkMsgHandler.java @@ -30,6 +30,7 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClient; +import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClientContext; import java.util.Collection; import java.util.Optional; @@ -77,4 +78,5 @@ public interface LwM2mUplinkMsgHandler { LwM2mValueConverter getConverter(); + LwM2mClientContext getClientContext(); } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2MTransportUtil.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2MTransportUtil.java index db8398cd57..e2e31f2768 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2MTransportUtil.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2MTransportUtil.java @@ -20,16 +20,13 @@ import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.elements.config.Configuration; import org.eclipse.leshan.core.model.LwM2mModel; import org.eclipse.leshan.core.model.ObjectLoader; -import org.eclipse.leshan.core.model.ObjectModel; import org.eclipse.leshan.core.model.ResourceModel; import org.eclipse.leshan.core.model.StaticModel; import org.eclipse.leshan.core.node.LwM2mMultipleResource; import org.eclipse.leshan.core.node.LwM2mPath; import org.eclipse.leshan.core.node.LwM2mResource; import org.eclipse.leshan.core.node.LwM2mSingleResource; -import org.eclipse.leshan.core.node.codec.CodecException; import org.eclipse.leshan.core.util.Hex; -import org.eclipse.leshan.server.registration.Registration; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceTransportType; @@ -49,8 +46,6 @@ import org.thingsboard.server.transport.lwm2m.server.ota.firmware.FirmwareUpdate import org.thingsboard.server.transport.lwm2m.server.ota.software.SoftwareUpdateResult; import org.thingsboard.server.transport.lwm2m.server.ota.software.SoftwareUpdateState; -import java.util.Arrays; -import java.util.Date; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -84,56 +79,6 @@ public class LwM2MTransportUtil { public static final String LOG_LWM2M_WARN = "warn"; public static final int BOOTSTRAP_DEFAULT_SHORT_ID_0 = 0; - public enum LwM2MClientStrategy { - CLIENT_STRATEGY_1(1, "Read only resources marked as observation"), - CLIENT_STRATEGY_2(2, "Read all client resources"); - - public int code; - public String type; - - LwM2MClientStrategy(int code, String type) { - this.code = code; - this.type = type; - } - - public static LwM2MClientStrategy fromStrategyClientByType(String type) { - for (LwM2MClientStrategy to : LwM2MClientStrategy.values()) { - if (to.type.equals(type)) { - return to; - } - } - throw new IllegalArgumentException(String.format("Unsupported Client Strategy type : %s", type)); - } - - public static LwM2MClientStrategy fromStrategyClientByCode(int code) { - for (LwM2MClientStrategy to : LwM2MClientStrategy.values()) { - if (to.code == code) { - return to; - } - } - throw new IllegalArgumentException(String.format("Unsupported Client Strategy code : %s", code)); - } - } - - public static boolean equalsResourceValue(Object valueOld, Object valueNew, ResourceModel.Type type, LwM2mPath - resourcePath) throws CodecException { - switch (type) { - case BOOLEAN: - case INTEGER: - case FLOAT: - return String.valueOf(valueOld).equals(String.valueOf(valueNew)); - case TIME: - return ((Date) valueOld).getTime() == ((Date) valueNew).getTime(); - case STRING: - case OBJLNK: - return valueOld.equals(valueNew); - case OPAQUE: - return Arrays.equals(Hex.decodeHex(((String) valueOld).toCharArray()), Hex.decodeHex(((String) valueNew).toCharArray())); - default: - throw new CodecException("Invalid value type for resource %s, type %s", resourcePath, type); - } - } - public static LwM2mOtaConvert convertOtaUpdateValueToString(String pathIdVer, Object value, ResourceModel.Type currentType) { String path = fromVersionedIdToObjectId(pathIdVer); LwM2mOtaConvert lwM2mOtaConvert = new LwM2mOtaConvert(); @@ -210,22 +155,8 @@ public class LwM2MTransportUtil { return null; } - public static String validPathIdVer(String pathIdVer, Registration registration) throws - IllegalArgumentException { - if (!pathIdVer.contains(LWM2M_SEPARATOR_PATH)) { - throw new IllegalArgumentException(String.format("Error:")); - } else { - String[] keyArray = pathIdVer.split(LWM2M_SEPARATOR_PATH); - if (keyArray.length > 1 && keyArray[1].split(LWM2M_SEPARATOR_KEY).length == 2) { - return pathIdVer; - } else { - return convertObjectIdToVersionedId(pathIdVer, registration); - } - } - } - - public static String convertObjectIdToVersionedId(String path, Registration registration) { - String ver = String.valueOf(registration.getSupportedObject().get(new LwM2mPath(path).getObjectId())); + public static String convertObjectIdToVersionedId(String path, LwM2mClient lwM2MClient) { + String ver = String.valueOf(lwM2MClient.getSupportedObjectVersion(new LwM2mPath(path).getObjectId())); return convertObjectIdToVerId(path, ver); } public static String convertObjectIdToVerId(String path, String ver) { @@ -243,14 +174,6 @@ public class LwM2MTransportUtil { } } - public static String validateObjectVerFromKey(String key) { - try { - return (key.split(LWM2M_SEPARATOR_PATH)[1].split(LWM2M_SEPARATOR_KEY)[1]); - } catch (Exception e) { - return ObjectModel.DEFAULT_VERSION; - } - } - /** * "UNSIGNED_INTEGER": // Number -> Integer Example: * Alarm Timestamp [32-bit unsigned integer] diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewayDeviceSessionContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewayDeviceSessionContext.java index f803f0fe3a..e174ad86bd 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewayDeviceSessionContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/AbstractGatewayDeviceSessionContext.java @@ -59,6 +59,8 @@ public abstract class AbstractGatewayDeviceSessionContext tenantAllowed = new ConcurrentHashMap<>(); private final ConcurrentMap> tenantDevices = new ConcurrentHashMap<>(); + private final ConcurrentMap> tenantGateways = new ConcurrentHashMap<>(); private final ConcurrentMap perTenantLimits = new ConcurrentHashMap<>(); private final ConcurrentMap perDeviceLimits = new ConcurrentHashMap<>(); + private final ConcurrentMap perGatewayLimits = new ConcurrentHashMap<>(); private final Map ipMap = new ConcurrentHashMap<>(); private final TransportTenantProfileCache tenantProfileCache; @@ -65,15 +72,20 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } @Override - public EntityType checkLimits(TenantId tenantId, DeviceId deviceId, int dataPoints) { + public TbPair checkLimits(TenantId tenantId, DeviceId gatewayId, DeviceId deviceId, int dataPoints) { if (!tenantAllowed.getOrDefault(tenantId, Boolean.TRUE)) { - return EntityType.API_USAGE_STATE; + return TbPair.of(EntityType.API_USAGE_STATE, false); } if (!checkEntityRateLimit(dataPoints, getTenantRateLimits(tenantId))) { - return EntityType.TENANT; + return TbPair.of(EntityType.TENANT, false); + } + + if (gatewayId != null && !checkEntityRateLimit(dataPoints, getGatewayRateLimits(tenantId, gatewayId))) { + return TbPair.of(EntityType.DEVICE, true); } + if (!checkEntityRateLimit(dataPoints, getDeviceRateLimits(tenantId, deviceId))) { - return EntityType.DEVICE; + return TbPair.of(EntityType.DEVICE, false); } return null; } @@ -89,24 +101,27 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi @Override public void update(TenantProfileUpdateResult update) { log.info("Received tenant profile update: {}", update.getProfile()); - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), true); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), false); + EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(update.getProfile(), TENANT_LIMITS); + EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(update.getProfile(), DEVICE_LIMITS); + EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(update.getProfile(), GATEWAY_LIMITS); for (TenantId tenantId : update.getAffectedTenants()) { - mergeLimits(tenantId, tenantRateLimitPrototype, perTenantLimits::get, perTenantLimits::put); - getTenantDevices(tenantId).forEach(deviceId -> { - mergeLimits(deviceId, deviceRateLimitPrototype, perDeviceLimits::get, perDeviceLimits::put); - }); + update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype); } } @Override public void update(TenantId tenantId) { - EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), true); - EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), false); + EntityTransportRateLimits tenantRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS); + EntityTransportRateLimits deviceRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); + EntityTransportRateLimits gatewayRateLimitPrototype = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); + update(tenantId, tenantRateLimitPrototype, deviceRateLimitPrototype, gatewayRateLimitPrototype); + } + + private void update(TenantId tenantId, EntityTransportRateLimits tenantRateLimitPrototype, + EntityTransportRateLimits deviceRateLimitPrototype, EntityTransportRateLimits gatewayRateLimitPrototype) { mergeLimits(tenantId, tenantRateLimitPrototype, perTenantLimits::get, perTenantLimits::put); - getTenantDevices(tenantId).forEach(deviceId -> { - mergeLimits(deviceId, deviceRateLimitPrototype, perDeviceLimits::get, perDeviceLimits::put); - }); + getTenantDevices(tenantId).forEach(deviceId -> mergeLimits(deviceId, deviceRateLimitPrototype, perDeviceLimits::get, perDeviceLimits::put)); + getTenantGateways(tenantId).forEach(deviceId -> mergeLimits(deviceId, gatewayRateLimitPrototype, perGatewayLimits::get, perGatewayLimits::put)); } @Override @@ -233,15 +248,34 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } } - private EntityTransportRateLimits createRateLimits(TenantProfile tenantProfile, boolean tenant) { + private EntityTransportRateLimits createRateLimits(TenantProfile tenantProfile, TransportLimitsType limitsType) { TenantProfileData profileData = tenantProfile.getProfileData(); DefaultTenantProfileConfiguration profile = (DefaultTenantProfileConfiguration) profileData.getConfiguration(); if (profile == null) { return new EntityTransportRateLimits(ALLOW, ALLOW, ALLOW); } else { - TransportRateLimit regularMsgRateLimit = newLimit(tenant ? profile.getTransportTenantMsgRateLimit() : profile.getTransportDeviceMsgRateLimit()); - TransportRateLimit telemetryMsgRateLimit = newLimit(tenant ? profile.getTransportTenantTelemetryMsgRateLimit() : profile.getTransportDeviceTelemetryMsgRateLimit()); - TransportRateLimit telemetryDpRateLimit = newLimit(tenant ? profile.getTransportTenantTelemetryDataPointsRateLimit() : profile.getTransportDeviceTelemetryDataPointsRateLimit()); + TransportRateLimit regularMsgRateLimit; + TransportRateLimit telemetryMsgRateLimit; + TransportRateLimit telemetryDpRateLimit; + switch (limitsType) { + case TENANT_LIMITS -> { + regularMsgRateLimit = newLimit(profile.getTransportTenantMsgRateLimit()); + telemetryMsgRateLimit = newLimit(profile.getTransportTenantTelemetryMsgRateLimit()); + telemetryDpRateLimit = newLimit(profile.getTransportTenantTelemetryDataPointsRateLimit()); + } + case DEVICE_LIMITS -> { + regularMsgRateLimit = newLimit(profile.getTransportDeviceMsgRateLimit()); + telemetryMsgRateLimit = newLimit(profile.getTransportDeviceTelemetryMsgRateLimit()); + telemetryDpRateLimit = newLimit(profile.getTransportDeviceTelemetryDataPointsRateLimit()); + } + case GATEWAY_LIMITS -> { + regularMsgRateLimit = newLimit(profile.getTransportGatewayMsgRateLimit()); + telemetryMsgRateLimit = newLimit(profile.getTransportGatewayTelemetryMsgRateLimit()); + telemetryDpRateLimit = newLimit(profile.getTransportGatewayTelemetryDataPointsRateLimit()); + } + default -> throw new IllegalStateException("Unknown limits type: " + limitsType); + } + return new EntityTransportRateLimits(regularMsgRateLimit, telemetryMsgRateLimit, telemetryDpRateLimit); } } @@ -251,21 +285,31 @@ public class DefaultTransportRateLimitService implements TransportRateLimitServi } private EntityTransportRateLimits getTenantRateLimits(TenantId tenantId) { - return perTenantLimits.computeIfAbsent(tenantId, k -> { - return createRateLimits(tenantProfileCache.get(tenantId), true); - }); + return perTenantLimits.computeIfAbsent(tenantId, k -> createRateLimits(tenantProfileCache.get(tenantId), TENANT_LIMITS)); } private EntityTransportRateLimits getDeviceRateLimits(TenantId tenantId, DeviceId deviceId) { return perDeviceLimits.computeIfAbsent(deviceId, k -> { - EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), false); + EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), DEVICE_LIMITS); getTenantDevices(tenantId).add(deviceId); return limits; }); } + private EntityTransportRateLimits getGatewayRateLimits(TenantId tenantId, DeviceId deviceId) { + return perGatewayLimits.computeIfAbsent(deviceId, k -> { + EntityTransportRateLimits limits = createRateLimits(tenantProfileCache.get(tenantId), GATEWAY_LIMITS); + getTenantGateways(tenantId).add(deviceId); + return limits; + }); + } + private Set getTenantDevices(TenantId tenantId) { return tenantDevices.computeIfAbsent(tenantId, id -> ConcurrentHashMap.newKeySet()); } + private Set getTenantGateways(TenantId tenantId) { + return tenantGateways.computeIfAbsent(tenantId, id -> ConcurrentHashMap.newKeySet()); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java new file mode 100644 index 0000000000..ab65b07414 --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportLimitsType.java @@ -0,0 +1,20 @@ +/** + * 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.common.transport.limits; + +public enum TransportLimitsType { + TENANT_LIMITS, DEVICE_LIMITS, GATEWAY_LIMITS +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportRateLimitService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportRateLimitService.java index 975602c5da..e33f9db240 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportRateLimitService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/limits/TransportRateLimitService.java @@ -18,13 +18,14 @@ package org.thingsboard.server.common.transport.limits; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.transport.profile.TenantProfileUpdateResult; import java.net.InetSocketAddress; public interface TransportRateLimitService { - EntityType checkLimits(TenantId tenantId, DeviceId deviceId, int dataPoints); + TbPair checkLimits(TenantId tenantId, DeviceId gatewayId, DeviceId deviceId, int dataPoints); void update(TenantProfileUpdateResult update); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 41886bd88a..9312370c34 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -21,6 +21,8 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.Gson; import com.google.gson.JsonObject; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.beans.factory.annotation.Autowired; @@ -97,6 +99,7 @@ import org.thingsboard.server.queue.TbQueueProducer; import org.thingsboard.server.queue.TbQueueRequestTemplate; import org.thingsboard.server.queue.common.AsyncCallbackTemplate; 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.TbServiceInfoProvider; import org.thingsboard.server.queue.discovery.TopicService; @@ -106,14 +109,12 @@ import org.thingsboard.server.queue.scheduler.SchedulerComponent; import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.queue.util.TbTransportComponent; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Random; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; @@ -179,19 +180,17 @@ public class DefaultTransportService extends TransportActivityManager implements protected TbQueueRequestTemplate, TbProtoQueueMsg> transportApiRequestTemplate; protected TbQueueProducer> ruleEngineMsgProducer; protected TbQueueProducer> tbCoreMsgProducer; - protected TbQueueConsumer> transportNotificationsConsumer; + protected QueueConsumerManager> transportNotificationsConsumer; protected MessagesStats ruleEngineProducerStats; protected MessagesStats tbCoreProducerStats; protected MessagesStats transportApiStats; protected ExecutorService transportCallbackExecutor; - private ExecutorService mainConsumerExecutor; + private ExecutorService consumerExecutor; private final Map toServerRpcPendingMap = new ConcurrentHashMap<>(); - private volatile boolean stopped = false; - public DefaultTransportService(PartitionService partitionService, TbServiceInfoProvider serviceInfoProvider, TbTransportQueueFactory queueProvider, @@ -232,42 +231,33 @@ public class DefaultTransportService extends TransportActivityManager implements transportApiRequestTemplate.setMessagesStats(transportApiStats); ruleEngineMsgProducer = producerProvider.getRuleEngineMsgProducer(); tbCoreMsgProducer = producerProvider.getTbCoreMsgProducer(); - transportNotificationsConsumer = queueProvider.createTransportNotificationsConsumer(); - TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_TRANSPORT, serviceInfoProvider.getServiceId()); - transportNotificationsConsumer.subscribe(Collections.singleton(tpi)); transportApiRequestTemplate.init(); - mainConsumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("transport-consumer")); + consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("transport-consumer")); + transportNotificationsConsumer = QueueConsumerManager.>builder() + .name("TB Transport") + .msgPackProcessor(this::processNotificationMsgs) + .pollInterval(notificationsPollDuration) + .consumerCreator(queueProvider::createTransportNotificationsConsumer) + .consumerExecutor(consumerExecutor) + .build(); } @AfterStartUp(order = AfterStartUp.TRANSPORT_SERVICE) public void start() { - mainConsumerExecutor.execute(() -> { - while (!stopped) { - try { - List> records = transportNotificationsConsumer.poll(notificationsPollDuration); - if (records.size() == 0) { - continue; - } - records.forEach(record -> { - try { - processToTransportMsg(record.getValue()); - } catch (Throwable e) { - log.warn("Failed to process the notification.", e); - } - }); - transportNotificationsConsumer.commit(); - } catch (Exception e) { - if (!stopped) { - log.warn("Failed to obtain messages from queue.", e); - try { - Thread.sleep(notificationsPollDuration); - } catch (InterruptedException e2) { - log.trace("Failed to wait until the server has capacity to handle new requests", e2); - } - } - } + TopicPartitionInfo tpi = topicService.getNotificationsTopic(ServiceType.TB_TRANSPORT, serviceInfoProvider.getServiceId()); + transportNotificationsConsumer.subscribe(Set.of(tpi)); + transportNotificationsConsumer.launch(); + } + + private void processNotificationMsgs(List> msgs, TbQueueConsumer> consumer) { + msgs.forEach(msg -> { + try { + processToTransportMsg(msg.getValue()); + } catch (Throwable e) { + log.warn("Failed to process the notification.", e); } }); + consumer.commit(); } private void invalidateRateLimits() { @@ -276,16 +266,14 @@ public class DefaultTransportService extends TransportActivityManager implements @PreDestroy public void destroy() { - stopped = true; - if (transportNotificationsConsumer != null) { - transportNotificationsConsumer.unsubscribe(); + transportNotificationsConsumer.stop(); } if (transportCallbackExecutor != null) { transportCallbackExecutor.shutdownNow(); } - if (mainConsumerExecutor != null) { - mainConsumerExecutor.shutdownNow(); + if (consumerExecutor != null) { + consumerExecutor.shutdownNow(); } if (transportApiRequestTemplate != null) { transportApiRequestTemplate.stop(); @@ -864,18 +852,27 @@ public class DefaultTransportService extends TransportActivityManager implements } TenantId tenantId = TenantId.fromUUID(new UUID(sessionInfo.getTenantIdMSB(), sessionInfo.getTenantIdLSB())); DeviceId deviceId = new DeviceId(new UUID(sessionInfo.getDeviceIdMSB(), sessionInfo.getDeviceIdLSB())); + DeviceId gatewayId = null; + if (sessionInfo.hasGatewayIdMSB() && sessionInfo.hasGatewayIdLSB()) { + gatewayId = new DeviceId(new UUID(sessionInfo.getGatewayIdMSB(), sessionInfo.getGatewayIdLSB())); + } - EntityType rateLimitedEntityType = rateLimitService.checkLimits(tenantId, deviceId, dataPoints); - if (rateLimitedEntityType == null) { + var rateLimitedPair = rateLimitService.checkLimits(tenantId, gatewayId, deviceId, dataPoints); + if (rateLimitedPair == null) { return true; } else { + var rateLimitedEntityType = rateLimitedPair.getFirst(); if (callback != null) { callback.onError(new TbRateLimitsException(rateLimitedEntityType)); } + if (rateLimitedEntityType == EntityType.DEVICE || rateLimitedEntityType == EntityType.TENANT) { + LimitedApi limitedApi = + rateLimitedEntityType == EntityType.TENANT ? LimitedApi.TRANSPORT_MESSAGES_PER_TENANT : + rateLimitedPair.getSecond() ? LimitedApi.TRANSPORT_MESSAGES_PER_GATEWAY : LimitedApi.TRANSPORT_MESSAGES_PER_DEVICE; notificationRuleProcessor.process(RateLimitsTrigger.builder() .tenantId(tenantId) - .api(rateLimitedEntityType == EntityType.DEVICE ? LimitedApi.TRANSPORT_MESSAGES_PER_DEVICE : LimitedApi.TRANSPORT_MESSAGES_PER_TENANT) + .api(limitedApi) .limitLevel(rateLimitedEntityType == EntityType.DEVICE ? deviceId : tenantId) .limitLevelEntityName(rateLimitedEntityType == EntityType.DEVICE ? sessionInfo.getDeviceName() : null) .build()); @@ -945,7 +942,6 @@ public class DefaultTransportService extends TransportActivityManager implements log.trace("ResourceUpdate - [{}] [{}]", id, mdRez); transportCallbackExecutor.submit(() -> mdRez.getListener().onResourceUpdate(msg)); }); - } else if (toSessionMsg.hasResourceDeleteMsg()) { TransportProtos.ResourceDeleteMsg msg = toSessionMsg.getResourceDeleteMsg(); TenantId tenantId = TenantId.fromUUID(new UUID(msg.getTenantIdMSB(), msg.getTenantIdLSB())); diff --git a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java index edf998dc8e..34171c19cd 100644 --- a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java +++ b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/DefaultClusterVersionControlService.java @@ -21,14 +21,13 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; import org.eclipse.jgit.errors.LargeObjectException; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.context.event.ApplicationReadyEvent; -import org.springframework.context.event.EventListener; -import org.springframework.core.annotation.Order; import org.springframework.stereotype.Service; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.EntityType; @@ -68,16 +67,16 @@ import org.thingsboard.server.gen.transport.TransportProtos.VersionedEntityInfoP 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.discovery.PartitionService; import org.thingsboard.server.queue.discovery.TbApplicationEventListener; import org.thingsboard.server.queue.discovery.TopicService; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.provider.TbVersionControlQueueFactory; +import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.queue.util.TbVersionControlComponent; -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; @@ -115,9 +114,8 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe private final Map pendingCommitMap = new HashMap<>(); private volatile ExecutorService consumerExecutor; - private volatile TbQueueConsumer> consumer; + private volatile QueueConsumerManager> consumer; private volatile TbQueueProducer> producer; - private volatile boolean stopped = false; @Value("${queue.vc.poll-interval:25}") private long pollDuration; @@ -134,20 +132,25 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe @PostConstruct public void init() { - consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("vc-consumer")); + consumerExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("vc-consumer")); var threadFactory = ThingsBoardThreadFactory.forName("vc-io-thread"); for (int i = 0; i < ioPoolSize; i++) { ioThreads.add(MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor(threadFactory))); } producer = producerProvider.getTbCoreNotificationsMsgProducer(); - consumer = queueFactory.createToVersionControlMsgConsumer(); + consumer = QueueConsumerManager.>builder() + .name("TB Version Control") + .msgPackProcessor(this::processMsgs) + .pollInterval(pollDuration) + .consumerCreator(queueFactory::createToVersionControlMsgConsumer) + .consumerExecutor(consumerExecutor) + .build(); } @PreDestroy public void stop() { - stopped = true; if (consumer != null) { - consumer.unsubscribe(); + consumer.stop(); } if (consumerExecutor != null) { consumerExecutor.shutdownNow(); @@ -179,48 +182,29 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe return ServiceType.TB_VC_EXECUTOR.equals(event.getServiceType()); } - @EventListener(ApplicationReadyEvent.class) - @Order(value = 2) - public void onApplicationEvent(ApplicationReadyEvent event) { - consumerExecutor.execute(() -> consumerLoop(consumer)); + @AfterStartUp(order = 2) + public void afterStartUp() { + consumer.launch(); } - void consumerLoop(TbQueueConsumer> consumer) { - while (!stopped && !consumer.isStopped()) { - List> futures = new ArrayList<>(); - try { - List> msgs = consumer.poll(pollDuration); - if (msgs.isEmpty()) { - continue; - } - for (TbProtoQueueMsg msgWrapper : msgs) { - ToVersionControlServiceMsg msg = msgWrapper.getValue(); - var ctx = new VersionControlRequestCtx(msg, msg.hasClearRepositoryRequest() ? null : ProtoUtils.fromProto(msg.getVcSettings())); - long startTs = System.currentTimeMillis(); - log.trace("[{}][{}] RECEIVED task: {}", ctx.getTenantId(), ctx.getRequestId(), msg); - int threadIdx = Math.abs(ctx.getTenantId().hashCode() % ioPoolSize); - ListenableFuture future = ioThreads.get(threadIdx).submit(() -> processMessage(ctx, msg)); - logTaskExecution(ctx, future, startTs); - futures.add(future); - } - try { - Futures.allAsList(futures).get(packProcessingTimeout, TimeUnit.MILLISECONDS); - } catch (TimeoutException e) { - log.info("Timeout for processing the version control tasks.", e); - } - consumer.commit(); - } catch (Exception e) { - if (!stopped) { - log.warn("Failed to obtain version control requests from queue.", e); - try { - Thread.sleep(pollDuration); - } catch (InterruptedException e2) { - log.trace("Failed to wait until the server has capacity to handle new version control messages", e2); - } - } - } + void processMsgs(List> msgs, TbQueueConsumer> consumer) throws Exception { + List> futures = new ArrayList<>(); + for (TbProtoQueueMsg msgWrapper : msgs) { + ToVersionControlServiceMsg msg = msgWrapper.getValue(); + var ctx = new VersionControlRequestCtx(msg, msg.hasClearRepositoryRequest() ? null : ProtoUtils.fromProto(msg.getVcSettings())); + long startTs = System.currentTimeMillis(); + log.trace("[{}][{}] RECEIVED task: {}", ctx.getTenantId(), ctx.getRequestId(), msg); + int threadIdx = Math.abs(ctx.getTenantId().hashCode() % ioPoolSize); + ListenableFuture future = ioThreads.get(threadIdx).submit(() -> processMessage(ctx, msg)); + logTaskExecution(ctx, future, startTs); + futures.add(future); + } + try { + Futures.allAsList(futures).get(packProcessingTimeout, TimeUnit.MILLISECONDS); + } catch (TimeoutException e) { + log.info("Timeout for processing the version control tasks.", e); } - log.info("TB Version Control request consumer stopped."); + consumer.commit(); } private Void processMessage(VersionControlRequestCtx ctx, ToVersionControlServiceMsg msg) { @@ -273,7 +257,7 @@ public class DefaultClusterVersionControlService extends TbApplicationEventListe var ids = vcService.listEntitiesAtVersion(ctx.getTenantId(), request.getVersionId(), path) .stream().skip(request.getOffset()).limit(request.getLimit()).collect(Collectors.toList()); if (!ids.isEmpty()) { - for (int i = 0; i < ids.size(); i++){ + for (int i = 0; i < ids.size(); i++) { VersionedEntityInfo info = ids.get(i); var data = vcService.getFileContentAtCommit(ctx.getTenantId(), getRelativePath(info.getExternalId().getEntityType(), info.getExternalId().getId().toString()), request.getVersionId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java index 05becc8bde..4dd0633420 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/JpaDaoConfig.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.thingsboard.server.dao.util.TbAutoConfiguration; @@ -28,7 +29,7 @@ import org.thingsboard.server.dao.util.TbAutoConfiguration; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sql", "org.thingsboard.server.dao.attributes", "org.thingsboard.server.dao.cache", "org.thingsboard.server.cache"}) -@EnableJpaRepositories("org.thingsboard.server.dao.sql") +@EnableJpaRepositories(value = "org.thingsboard.server.dao.sql", bootstrapMode = BootstrapMode.LAZY) @EntityScan("org.thingsboard.server.dao.model.sql") @EnableTransactionManagement public class JpaDaoConfig { diff --git a/dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java index 9cf9532aea..09553fb973 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/SqlTimeseriesDaoConfig.java @@ -19,14 +19,14 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; -import org.thingsboard.server.dao.util.SqlTsOrTsLatestAnyDao; import org.thingsboard.server.dao.util.TbAutoConfiguration; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.dictionary"}) -@EnableJpaRepositories({"org.thingsboard.server.dao.sqlts.dictionary"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.dictionary"}, bootstrapMode = BootstrapMode.LAZY) @EntityScan({"org.thingsboard.server.dao.model.sqlts.dictionary"}) @EnableTransactionManagement public class SqlTimeseriesDaoConfig { diff --git a/dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java index 6bdf7c2170..bbd31846db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/SqlTsDaoConfig.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.thingsboard.server.dao.util.SqlTsDao; import org.thingsboard.server.dao.util.TbAutoConfiguration; @@ -26,7 +27,7 @@ import org.thingsboard.server.dao.util.TbAutoConfiguration; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.sql", "org.thingsboard.server.dao.sqlts.insert.sql"}) -@EnableJpaRepositories({"org.thingsboard.server.dao.sqlts.ts", "org.thingsboard.server.dao.sqlts.insert.sql"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.ts", "org.thingsboard.server.dao.sqlts.insert.sql"}, bootstrapMode = BootstrapMode.LAZY) @EntityScan({"org.thingsboard.server.dao.model.sqlts.ts"}) @EnableTransactionManagement @SqlTsDao diff --git a/dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java index 1793c4ed76..6fcf21a0a8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/SqlTsLatestDaoConfig.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.thingsboard.server.dao.util.SqlTsLatestDao; import org.thingsboard.server.dao.util.TbAutoConfiguration; @@ -26,7 +27,7 @@ import org.thingsboard.server.dao.util.TbAutoConfiguration; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.sql"}) -@EnableJpaRepositories({"org.thingsboard.server.dao.sqlts.insert.latest.sql", "org.thingsboard.server.dao.sqlts.latest"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.insert.latest.sql", "org.thingsboard.server.dao.sqlts.latest"}, bootstrapMode = BootstrapMode.LAZY) @EntityScan({"org.thingsboard.server.dao.model.sqlts.latest"}) @EnableTransactionManagement @SqlTsLatestDao diff --git a/dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java index 16617c12a8..c134b88897 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/TimescaleDaoConfig.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.thingsboard.server.dao.util.TbAutoConfiguration; import org.thingsboard.server.dao.util.TimescaleDBTsDao; @@ -26,7 +27,7 @@ import org.thingsboard.server.dao.util.TimescaleDBTsDao; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.timescale"}) -@EnableJpaRepositories({"org.thingsboard.server.dao.sqlts.timescale", "org.thingsboard.server.dao.sqlts.insert.timescale"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.timescale", "org.thingsboard.server.dao.sqlts.insert.timescale"}, bootstrapMode = BootstrapMode.LAZY) @EntityScan({"org.thingsboard.server.dao.model.sqlts.timescale"}) @EnableTransactionManagement @TimescaleDBTsDao diff --git a/dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java b/dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java index f0e6fc362b..f0e74f1c3d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java +++ b/dao/src/main/java/org/thingsboard/server/dao/TimescaleTsLatestDaoConfig.java @@ -19,6 +19,7 @@ import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.ComponentScan; import org.springframework.context.annotation.Configuration; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.data.repository.config.BootstrapMode; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.thingsboard.server.dao.util.TbAutoConfiguration; import org.thingsboard.server.dao.util.TimescaleDBTsLatestDao; @@ -26,7 +27,7 @@ import org.thingsboard.server.dao.util.TimescaleDBTsLatestDao; @Configuration @TbAutoConfiguration @ComponentScan({"org.thingsboard.server.dao.sqlts.timescale"}) -@EnableJpaRepositories({"org.thingsboard.server.dao.sqlts.insert.latest.sql", "org.thingsboard.server.dao.sqlts.latest"}) +@EnableJpaRepositories(value = {"org.thingsboard.server.dao.sqlts.insert.latest.sql", "org.thingsboard.server.dao.sqlts.latest"}, bootstrapMode = BootstrapMode.LAZY) @EntityScan({"org.thingsboard.server.dao.model.sqlts.latest"}) @EnableTransactionManagement @TimescaleDBTsLatestDao diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java new file mode 100644 index 0000000000..4e8a2cd503 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/BaseMobileAppSettingsService.java @@ -0,0 +1,112 @@ +/** + * 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.dao.mobile; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.AndroidConfig; +import org.thingsboard.server.common.data.mobile.BadgePosition; +import org.thingsboard.server.common.data.mobile.BadgeStyle; +import org.thingsboard.server.common.data.mobile.IosConfig; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.mobile.QRCodeConfig; +import org.thingsboard.server.dao.entity.AbstractCachedEntityService; +import org.thingsboard.server.dao.service.DataValidator; + +import java.util.Map; + +import static org.thingsboard.server.dao.service.Validator.validateId; + +@Service +@Slf4j +@RequiredArgsConstructor +public class BaseMobileAppSettingsService extends AbstractCachedEntityService implements MobileAppSettingsService { + + public static final String INCORRECT_TENANT_ID = "Incorrect tenantId "; + private static final String DEFAULT_QR_CODE_LABEL = "Scan to connect or download mobile app"; + + private final MobileAppSettingsDao mobileAppSettingsDao; + private final DataValidator mobileAppSettingsDataValidator; + + @Override + public MobileAppSettings saveMobileAppSettings(TenantId tenantId, MobileAppSettings mobileAppSettings) { + mobileAppSettingsDataValidator.validate(mobileAppSettings, s -> tenantId); + try { + MobileAppSettings savedMobileAppSettings = mobileAppSettingsDao.save(tenantId, mobileAppSettings); + publishEvictEvent(new MobileAppSettingsEvictEvent(tenantId)); + return savedMobileAppSettings; + } catch (Exception e) { + handleEvictEvent(new MobileAppSettingsEvictEvent(tenantId)); + checkConstraintViolation(e, Map.of( + "mobile_app_settings_tenant_id_unq_key", "Mobile application for specified tenant already exists!" + )); + throw e; + } + } + + @Override + public MobileAppSettings getMobileAppSettings(TenantId tenantId) { + log.trace("Executing getMobileAppSettings for tenant [{}] ", tenantId); + MobileAppSettings mobileAppSettings = cache.getAndPutInTransaction(tenantId, + () -> mobileAppSettingsDao.findByTenantId(tenantId), true); + return constructMobileAppSettings(mobileAppSettings); + } + + @Override + public void deleteByTenantId(TenantId tenantId) { + log.trace("Executing deleteByTenantId, tenantId [{}]", tenantId); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + mobileAppSettingsDao.removeByTenantId(tenantId); + } + + @TransactionalEventListener(classes = MobileAppSettingsEvictEvent.class) + @Override + public void handleEvictEvent(MobileAppSettingsEvictEvent event) { + cache.evict(event.getTenantId()); + } + + private MobileAppSettings constructMobileAppSettings(MobileAppSettings mobileAppSettings) { + if (mobileAppSettings == null) { + mobileAppSettings = new MobileAppSettings(); + mobileAppSettings.setUseDefaultApp(true); + + AndroidConfig androidConfig = AndroidConfig.builder() + .enabled(true) + .build(); + IosConfig iosConfig = IosConfig.builder() + .enabled(true) + .build(); + QRCodeConfig qrCodeConfig = QRCodeConfig.builder() + .showOnHomePage(true) + .qrCodeLabelEnabled(true) + .qrCodeLabel(DEFAULT_QR_CODE_LABEL) + .badgeEnabled(true) + .badgePosition(BadgePosition.RIGHT) + .badgeStyle(BadgeStyle.ORIGINAL) + .badgeEnabled(true) + .build(); + + mobileAppSettings.setQrCodeConfig(qrCodeConfig); + mobileAppSettings.setAndroidConfig(androidConfig); + mobileAppSettings.setIosConfig(iosConfig); + } + return mobileAppSettings; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsCaffeineCache.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsCaffeineCache.java new file mode 100644 index 0000000000..9e15ae8572 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsCaffeineCache.java @@ -0,0 +1,34 @@ +/** + * 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.dao.mobile; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CaffeineTbTransactionalCache; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "caffeine", matchIfMissing = true) +@Service("MobileAppCache") +public class MobileAppSettingsCaffeineCache extends CaffeineTbTransactionalCache { + + public MobileAppSettingsCaffeineCache(CacheManager cacheManager) { + super(cacheManager, CacheConstants.MOBILE_APP_SETTINGS_CACHE); + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java new file mode 100644 index 0000000000..094ead1a5b --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsDao.java @@ -0,0 +1,28 @@ +/** + * 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.dao.mobile; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.dao.Dao; + + +public interface MobileAppSettingsDao extends Dao { + + MobileAppSettings findByTenantId(TenantId tenantId); + + void removeByTenantId(TenantId tenantId); +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsEvictEvent.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsEvictEvent.java new file mode 100644 index 0000000000..459e61ef6d --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsEvictEvent.java @@ -0,0 +1,24 @@ +/** + * 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.dao.mobile; + +import lombok.Data; +import org.thingsboard.server.common.data.id.TenantId; + +@Data +public class MobileAppSettingsEvictEvent { + private final TenantId tenantId; +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsRedisCache.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsRedisCache.java new file mode 100644 index 0000000000..aac802c1ec --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsRedisCache.java @@ -0,0 +1,36 @@ +/** + * 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.dao.mobile; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.stereotype.Service; +import org.thingsboard.server.cache.CacheSpecsMap; +import org.thingsboard.server.cache.RedisTbTransactionalCache; +import org.thingsboard.server.cache.TBRedisCacheConfiguration; +import org.thingsboard.server.cache.TbJsonRedisSerializer; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; + +@ConditionalOnProperty(prefix = "cache", value = "type", havingValue = "redis") +@Service("MobileAppCache") +public class MobileAppSettingsRedisCache extends RedisTbTransactionalCache { + + public MobileAppSettingsRedisCache(TBRedisCacheConfiguration configuration, CacheSpecsMap cacheSpecsMap, RedisConnectionFactory connectionFactory) { + super(CacheConstants.MOBILE_APP_SETTINGS_CACHE, cacheSpecsMap, connectionFactory, configuration, new TbJsonRedisSerializer<>(MobileAppSettings.class)); + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsService.java b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsService.java new file mode 100644 index 0000000000..4645947382 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/mobile/MobileAppSettingsService.java @@ -0,0 +1,29 @@ +/** + * 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.dao.mobile; + +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; + +public interface MobileAppSettingsService { + + MobileAppSettings saveMobileAppSettings(TenantId tenantId, MobileAppSettings settings); + + MobileAppSettings getMobileAppSettings(TenantId tenantId); + + void deleteByTenantId(TenantId tenantId); + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 8f0502efbf..fb77ad6987 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -660,6 +660,15 @@ public class ModelConstants { public static final String NOTIFICATION_TEMPLATE_NOTIFICATION_TYPE_PROPERTY = "notification_type"; public static final String NOTIFICATION_TEMPLATE_CONFIGURATION_PROPERTY = "configuration"; + /** + * Mobile application settings constants. + */ + public static final String MOBILE_APP_SETTINGS_TABLE_NAME = "mobile_app_settings"; + public static final String MOBILE_APP_SETTINGS_USE_DEFAULT_APP_PROPERTY = "use_default_app"; + public static final String MOBILE_APP_SETTINGS_ANDROID_CONFIG_PROPERTY = "android_config"; + public static final String MOBILE_APP_SETTINGS_IOS_CONFIG_PROPERTY = "ios_config"; + public static final String MOBILE_APP_SETTINGS_QR_CODE_CONFIG_PROPERTY = "qr_code_config"; + protected static final String[] NONE_AGGREGATION_COLUMNS = new String[]{LONG_VALUE_COLUMN, DOUBLE_VALUE_COLUMN, BOOLEAN_VALUE_COLUMN, STRING_VALUE_COLUMN, JSON_VALUE_COLUMN, KEY_COLUMN, TS_COLUMN}; protected static final String[] COUNT_AGGREGATION_COLUMNS = new String[]{count(LONG_VALUE_COLUMN), count(DOUBLE_VALUE_COLUMN), count(BOOLEAN_VALUE_COLUMN), count(STRING_VALUE_COLUMN), count(JSON_VALUE_COLUMN), max(TS_COLUMN)}; diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java new file mode 100644 index 0000000000..cd38edd9cb --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/MobileAppSettingsEntity.java @@ -0,0 +1,85 @@ +/** + * 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.dao.model.sql; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import org.thingsboard.server.common.data.id.MobileAppSettingsId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.AndroidConfig; +import org.thingsboard.server.common.data.mobile.IosConfig; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.mobile.QRCodeConfig; +import org.thingsboard.server.dao.model.BaseSqlEntity; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.util.mapping.JsonConverter; + +import java.util.UUID; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@Entity +@Table(name = ModelConstants.MOBILE_APP_SETTINGS_TABLE_NAME) +public class MobileAppSettingsEntity extends BaseSqlEntity { + + @Column(name = ModelConstants.TENANT_ID_COLUMN, columnDefinition = "uuid") + protected UUID tenantId; + + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_USE_DEFAULT_APP_PROPERTY) + private boolean useDefaultApp; + + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_ANDROID_CONFIG_PROPERTY) + private JsonNode androidConfig; + + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_IOS_CONFIG_PROPERTY) + private JsonNode iosConfig; + + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.MOBILE_APP_SETTINGS_QR_CODE_CONFIG_PROPERTY) + private JsonNode qrCodeConfig; + + public MobileAppSettingsEntity(MobileAppSettings mobileAppSettings) { + this.setId(mobileAppSettings.getUuidId()); + this.setCreatedTime(mobileAppSettings.getCreatedTime()); + this.tenantId = mobileAppSettings.getTenantId().getId(); + this.useDefaultApp = mobileAppSettings.isUseDefaultApp(); + this.androidConfig = toJson(mobileAppSettings.getAndroidConfig()); + this.iosConfig = toJson(mobileAppSettings.getIosConfig()); + this.qrCodeConfig = toJson(mobileAppSettings.getQrCodeConfig()); + } + + @Override + public MobileAppSettings toData() { + MobileAppSettings mobileAppSettings = new MobileAppSettings(new MobileAppSettingsId(getUuid())); + mobileAppSettings.setCreatedTime(createdTime); + mobileAppSettings.setTenantId(TenantId.fromUUID(tenantId)); + mobileAppSettings.setUseDefaultApp(useDefaultApp); + mobileAppSettings.setAndroidConfig(fromJson(androidConfig, AndroidConfig.class)); + mobileAppSettings.setIosConfig(fromJson(iosConfig, IosConfig.class)); + mobileAppSettings.setQrCodeConfig(fromJson(qrCodeConfig, QRCodeConfig.class)); + return mobileAppSettings; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java new file mode 100644 index 0000000000..c867b08cc4 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/MobileAppSettingsDataValidator.java @@ -0,0 +1,51 @@ +/** + * 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.dao.service.validator; + +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.AndroidConfig; +import org.thingsboard.server.common.data.mobile.IosConfig; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.common.data.mobile.QRCodeConfig; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.DataValidator; + +@Component +@AllArgsConstructor +public class MobileAppSettingsDataValidator extends DataValidator { + + @Override + protected void validateDataImpl(TenantId tenantId, MobileAppSettings mobileAppSettings) { + AndroidConfig androidConfig = mobileAppSettings.getAndroidConfig(); + IosConfig iosConfig = mobileAppSettings.getIosConfig(); + QRCodeConfig qrCodeConfig = mobileAppSettings.getQrCodeConfig(); + if (!mobileAppSettings.isUseDefaultApp() && (androidConfig == null || iosConfig == null)) { + throw new DataValidationException("Android/ios settings are required to use custom application!"); + } + if (qrCodeConfig == null) { + throw new DataValidationException("Qr code configuration is required!"); + } + if (androidConfig != null && androidConfig.isEnabled() && !mobileAppSettings.isUseDefaultApp() && + (androidConfig.getAppPackage() == null || androidConfig.getSha256CertFingerprints() == null)) { + throw new DataValidationException("Application package and sha256 cert fingerprints are required for custom android application!"); + } + if (iosConfig != null && iosConfig.isEnabled() && !mobileAppSettings.isUseDefaultApp() && iosConfig.getAppId() == null) { + throw new DataValidationException("Application id is required for custom ios application!"); + } + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java index f65e0db1c6..bf4798fa4d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/asset/AssetRepository.java @@ -41,7 +41,9 @@ public interface AssetRepository extends JpaRepository, Expor AssetInfoEntity findAssetInfoById(@Param("assetId") UUID assetId); @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.type, CONCAT('%', :textSearch, '%')) = true)") Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); @@ -54,14 +56,16 @@ public interface AssetRepository extends JpaRepository, Expor "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + " OR ilike(p.name, CONCAT('%', :textSearch, '%')) = true " + - " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true) ") + " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true)") Page findAssetInfosByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.type, CONCAT('%', :textSearch, '%')) = true)") Page findByTenantIdAndCustomerId(@Param("tenantId") UUID tenantId, @Param("customerId") UUID customerId, @Param("textSearch") String textSearch, @@ -69,7 +73,8 @@ public interface AssetRepository extends JpaRepository, Expor @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.assetProfileId = :profileId " + - "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true)") + "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :searchText, '%')) = true)") Page findByTenantIdAndProfileId(@Param("tenantId") UUID tenantId, @Param("profileId") UUID profileId, @Param("searchText") String searchText, @@ -81,7 +86,10 @@ public interface AssetRepository extends JpaRepository, Expor "LEFT JOIN AssetProfileEntity p on p.id = a.assetProfileId " + "WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId " + - "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true)") + "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(c.title, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(p.name, CONCAT('%', :searchText, '%')) = true) ") Page findAssetInfosByTenantIdAndCustomerId(@Param("tenantId") UUID tenantId, @Param("customerId") UUID customerId, @Param("searchText") String searchText, @@ -95,7 +103,8 @@ public interface AssetRepository extends JpaRepository, Expor @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.type = :type " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true)") Page findByTenantIdAndType(@Param("tenantId") UUID tenantId, @Param("type") String type, @Param("textSearch") String textSearch, @@ -123,7 +132,8 @@ public interface AssetRepository extends JpaRepository, Expor "AND a.assetProfileId = :assetProfileId " + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + - " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true) ") + " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.type, CONCAT('%', :textSearch, '%')) = true) ") Page findAssetInfosByTenantIdAndAssetProfileId(@Param("tenantId") UUID tenantId, @Param("assetProfileId") UUID assetProfileId, @Param("textSearch") String textSearch, @@ -132,7 +142,8 @@ public interface AssetRepository extends JpaRepository, Expor @Query("SELECT a FROM AssetEntity a WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId AND a.type = :type " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true) ") Page findByTenantIdAndCustomerIdAndType(@Param("tenantId") UUID tenantId, @Param("customerId") UUID customerId, @Param("type") String type, @@ -146,7 +157,9 @@ public interface AssetRepository extends JpaRepository, Expor "WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId " + "AND a.type = :type " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true) ") Page findAssetInfosByTenantIdAndCustomerIdAndType(@Param("tenantId") UUID tenantId, @Param("customerId") UUID customerId, @Param("type") String type, @@ -160,7 +173,10 @@ public interface AssetRepository extends JpaRepository, Expor "WHERE a.tenantId = :tenantId " + "AND a.customerId = :customerId " + "AND a.assetProfileId = :assetProfileId " + - "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true)") + "AND (:textSearch IS NULL OR ilike(a.name, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(c.title, CONCAT('%', :textSearch, '%')) = true " + + " OR ilike(a.type, CONCAT('%', :textSearch, '%')) = true) ") Page findAssetInfosByTenantIdAndCustomerIdAndAssetProfileId(@Param("tenantId") UUID tenantId, @Param("customerId") UUID customerId, @Param("assetProfileId") UUID assetProfileId, @@ -172,7 +188,9 @@ public interface AssetRepository extends JpaRepository, Expor @Query("SELECT a FROM AssetEntity a, RelationEntity re WHERE a.tenantId = :tenantId " + "AND a.id = re.toId AND re.toType = 'ASSET' AND re.relationTypeGroup = 'EDGE' " + "AND re.relationType = 'Contains' AND re.fromId = :edgeId AND re.fromType = 'EDGE' " + - "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true)") + "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(a.type, CONCAT('%', :searchText, '%')) = true) ") Page findByTenantIdAndEdgeId(@Param("tenantId") UUID tenantId, @Param("edgeId") UUID edgeId, @Param("searchText") String searchText, @@ -182,7 +200,8 @@ public interface AssetRepository extends JpaRepository, Expor "AND a.id = re.toId AND re.toType = 'ASSET' AND re.relationTypeGroup = 'EDGE' " + "AND re.relationType = 'Contains' AND re.fromId = :edgeId AND re.fromType = 'EDGE' " + "AND a.type = :type " + - "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true)") + "AND (:searchText IS NULL OR ilike(a.name, CONCAT('%', :searchText, '%')) = true " + + " OR ilike(a.label, CONCAT('%', :searchText, '%')) = true) ") Page findByTenantIdAndEdgeIdAndType(@Param("tenantId") UUID tenantId, @Param("edgeId") UUID edgeId, @Param("type") String type, diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java index 073dfd7d95..ddd6c3c881 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java @@ -45,7 +45,7 @@ public interface AttributeKvRepository extends JpaRepository findAllKeysByDeviceProfileId(@Param("tenantId") UUID tenantId, - @Param("deviceProfileId") UUID deviceProfileId); + @Param("deviceProfileId") UUID deviceProfileId); @Query(value = "SELECT DISTINCT attribute_key FROM attribute_kv WHERE " + "entity_id in (SELECT id FROM device WHERE tenant_id = :tenantId limit 100) ORDER BY attribute_key", nativeQuery = true) @@ -58,6 +58,6 @@ public interface AttributeKvRepository extends JpaRepository findAllKeysByEntityIdsAndAttributeType(@Param("entityIds") List entityIds, - @Param("attributeType") String attributeType); + @Param("attributeType") int attributeType); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java index 3bf8e43c70..320e0e67fb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/JpaAttributeDao.java @@ -173,7 +173,7 @@ public class JpaAttributeDao extends JpaAbstractDaoListeningExecutorService impl @Override public List findAllKeysByEntityIdsAndAttributeType(TenantId tenantId, List entityIds, String attributeType) { return attributeKvRepository - .findAllKeysByEntityIdsAndAttributeType(entityIds.stream().map(EntityId::getId).collect(Collectors.toList()), attributeType) + .findAllKeysByEntityIdsAndAttributeType(entityIds.stream().map(EntityId::getId).collect(Collectors.toList()), AttributeScope.valueOf(attributeType).getId()) .stream().map(id -> keyDictionaryDao.getKey(id)).collect(Collectors.toList()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppSettingsDao.java new file mode 100644 index 0000000000..e4286bb949 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/JpaMobileAppSettingsDao.java @@ -0,0 +1,61 @@ +/** + * 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.dao.sql.mobile; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.mobile.MobileAppSettings; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.mobile.MobileAppSettingsDao; +import org.thingsboard.server.dao.model.sql.MobileAppSettingsEntity; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; + +import java.util.UUID; + + +@Component +@Slf4j +@SqlDao +public class JpaMobileAppSettingsDao extends JpaAbstractDao implements MobileAppSettingsDao { + + @Autowired + private MobileAppSettingsRepository mobileAppSettingsRepository; + + + @Override + public MobileAppSettings findByTenantId(TenantId tenantId) { + return DaoUtil.getData(mobileAppSettingsRepository.findByTenantId(tenantId.getId())); + } + + @Override + public void removeByTenantId(TenantId tenantId) { + mobileAppSettingsRepository.deleteByTenantId(tenantId.getId()); + } + + @Override + protected Class getEntityClass() { + return MobileAppSettingsEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return mobileAppSettingsRepository; + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java new file mode 100644 index 0000000000..bb6e62f3fe --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/mobile/MobileAppSettingsRepository.java @@ -0,0 +1,36 @@ +/** + * 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.dao.sql.mobile; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.dao.model.sql.MobileAppSettingsEntity; + +import java.util.UUID; + + +public interface MobileAppSettingsRepository extends JpaRepository { + + MobileAppSettingsEntity findByTenantId(@Param("tenantId") UUID tenantId); + + @Transactional + @Modifying + @Query("DELETE FROM MobileAppSettingsEntity r WHERE r.tenantId = :tenantId") + void deleteByTenantId(@Param("tenantId") UUID tenantId); +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java index a711886e68..05da22f9a5 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultAlarmQueryRepository.java @@ -206,8 +206,13 @@ public class DefaultAlarmQueryRepository implements AlarmQueryRepository { long startTs; long endTs; if (pageLink.getTimeWindow() > 0) { - endTs = System.currentTimeMillis(); - startTs = endTs - pageLink.getTimeWindow(); + if (pageLink.getStartTs() > 0) { + startTs = pageLink.getStartTs(); + endTs = startTs + pageLink.getTimeWindow(); + } else { + endTs = System.currentTimeMillis(); + startTs = endTs - pageLink.getTimeWindow(); + } } else { startTs = pageLink.getStartTs(); endTs = pageLink.getEndTs(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 41680f76dd..b17cef53db 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -38,6 +38,9 @@ import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; +import org.thingsboard.server.dao.mobile.MobileAppSettingsService; +import org.thingsboard.server.dao.notification.NotificationRequestService; +import org.thingsboard.server.dao.notification.NotificationRuleService; import org.thingsboard.server.dao.notification.NotificationSettingsService; import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; @@ -78,6 +81,8 @@ public class TenantServiceImpl extends AbstractCachedEntityService existsTenantCache; @@ -161,6 +166,7 @@ public class TenantServiceImpl extends AbstractCachedEntityService> repositories; + @Test public void givenAllEntityTypes_whenGetDao_thenAllPresent() { for (EntityType entityType : EntityType.values()) { @@ -73,4 +78,14 @@ public class EntityDaoRegistryTest extends AbstractServiceTest { } } + /* + * Verifying that all the repositories are successfully bootstrapped, when using Lazy Jpa bootstrap mode + * */ + @Test + public void testJpaRepositories() { + for (var repository : repositories) { + repository.count(); + } + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java index 707155c86c..98a8cf9d47 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java @@ -21,6 +21,7 @@ import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListeningExecutorService; import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; +import org.awaitility.Awaitility; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -47,6 +48,8 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static org.assertj.core.api.Assertions.assertThat; + /** * @author Andrew Shvayka */ @@ -221,6 +224,45 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { Assert.assertEquals(NEW_VALUE, value.get(1)); } + @Test + public void testFindAllKeysByEntityId() { + var tenantId = new TenantId(UUID.randomUUID()); + var deviceId = new DeviceId(UUID.randomUUID()); + saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key1", "123"); + saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key2", "123"); + + Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + List keys = attributesService.findAllKeysByEntityIds(tenantId, List.of(deviceId)); + assertThat(keys).containsOnly("key1", "key2"); + }); + } + + @Test + public void testFindAllKeysByEntityIdAndAttributeType() { + var tenantId = new TenantId(UUID.randomUUID()); + var deviceId = new DeviceId(UUID.randomUUID()); + saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key1", "123"); + saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key2", "123"); + + Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + List keys = attributesService.findAllKeysByEntityIds(tenantId, List.of(deviceId), AttributeScope.SERVER_SCOPE.name()); + assertThat(keys).containsOnly("key1", "key2"); + }); + } + + @Test + public void testFindAllByEntityIdAndAttributeType() { + var tenantId = new TenantId(UUID.randomUUID()); + var deviceId = new DeviceId(UUID.randomUUID()); + saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key1", "123"); + saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key2", "123"); + + Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { + List attributes = attributesService.findAll(tenantId, deviceId, AttributeScope.SERVER_SCOPE).get(); + assertThat(attributes).extracting(KvEntry::getKey).containsOnly("key1", "key2"); + }); + } + private void testConcurrentFetchAndUpdate(TenantId tenantId, DeviceId deviceId, ListeningExecutorService pool) throws Exception { var scope = AttributeScope.SERVER_SCOPE; var key = "TEST"; diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/asset/JpaAssetDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/asset/JpaAssetDaoTest.java index bd6669f471..3e4b7ca38a 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/asset/JpaAssetDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/asset/JpaAssetDaoTest.java @@ -78,7 +78,7 @@ public class JpaAssetDaoTest extends AbstractJpaDaoTest { UUID assetId = Uuids.timeBased(); UUID tenantId = i % 2 == 0 ? tenantId1 : tenantId2; UUID customerId = i % 2 == 0 ? customerId1 : customerId2; - assets.add(saveAsset(assetId, tenantId, customerId, "ASSET_" + i)); + assets.add(saveAsset(assetId, tenantId, customerId, "ASSET_" + i, "label_" + i)); } assertEquals(assets.size(), assetDao.find(TenantId.fromUUID(tenantId1)).size()); } @@ -131,6 +131,21 @@ public class JpaAssetDaoTest extends AbstractJpaDaoTest { assertEquals(0, assets3.getData().size()); } + @Test + public void testFindAssetsByTenantIdAndCustomerIdByLabel() { + PageLink pageLink = new PageLink(20, 0, "label_"); + PageData assets1 = assetDao.findAssetsByTenantIdAndCustomerId(tenantId1, customerId1, pageLink); + assertEquals(20, assets1.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData assets2 = assetDao.findAssetsByTenantIdAndCustomerId(tenantId1, customerId1, pageLink); + assertEquals(10, assets2.getData().size()); + + pageLink = pageLink.nextPageLink(); + PageData assets3 = assetDao.findAssetsByTenantIdAndCustomerId(tenantId1, customerId1, pageLink); + assertEquals(0, assets3.getData().size()); + } + @Test public void testFindAssetsByTenantIdAndIdsAsync() throws ExecutionException, InterruptedException, TimeoutException { List searchIds = getAssetsUuids(tenantId1); @@ -180,21 +195,31 @@ public class JpaAssetDaoTest extends AbstractJpaDaoTest { @Test public void testFindAssetsByTenantIdAndType() { String type = "TYPE_2"; - assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET", type)); + String testLabel = "test_label"; + assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET", type, testLabel)); List foundedAssetsByType = assetDao .findAssetsByTenantIdAndType(tenantId2, type, new PageLink(3)).getData(); compareFoundedAssetByType(foundedAssetsByType, type); + + List foundedAssetsByTypeAndLabel = assetDao + .findAssetsByTenantIdAndType(tenantId2, type, new PageLink(3, 0, testLabel)).getData(); + assertEquals(1, foundedAssetsByTypeAndLabel.size()); } @Test public void testFindAssetsByTenantIdAndCustomerIdAndType() { String type = "TYPE_2"; - assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET", type)); + String testLabel = "test_label"; + assets.add(saveAsset(Uuids.timeBased(), tenantId2, customerId2, "TEST_ASSET", type, testLabel)); List foundedAssetsByType = assetDao .findAssetsByTenantIdAndCustomerIdAndType(tenantId2, customerId2, type, new PageLink(3)).getData(); compareFoundedAssetByType(foundedAssetsByType, type); + + List foundedAssetsByTypeAndLabel = assetDao + .findAssetsByTenantIdAndCustomerIdAndType(tenantId2, customerId2, type, new PageLink(3, 0, testLabel)).getData(); + assertEquals(1, foundedAssetsByTypeAndLabel.size()); } private void compareFoundedAssetByType(List foundedAssetsByType, String type) { @@ -228,10 +253,14 @@ public class JpaAssetDaoTest extends AbstractJpaDaoTest { } private Asset saveAsset(UUID id, UUID tenantId, UUID customerId, String name) { - return saveAsset(id, tenantId, customerId, name, null); + return saveAsset(id, tenantId, customerId, name, null, null); + } + + private Asset saveAsset(UUID id, UUID tenantId, UUID customerId, String name, String label) { + return saveAsset(id, tenantId, customerId, name, null, label); } - private Asset saveAsset(UUID id, UUID tenantId, UUID customerId, String name, String type) { + private Asset saveAsset(UUID id, UUID tenantId, UUID customerId, String name, String type, String label) { if (type == null) { type = "default"; } @@ -241,6 +270,7 @@ public class JpaAssetDaoTest extends AbstractJpaDaoTest { asset.setCustomerId(new CustomerId(customerId)); asset.setName(name); asset.setType(type); + asset.setLabel(label); asset.setAssetProfileId(assetProfileId(type)); return assetDao.save(TenantId.fromUUID(tenantId), asset); } diff --git a/docker/haproxy/config/haproxy.cfg b/docker/haproxy/config/haproxy.cfg index 757efe692f..b76fe29276 100644 --- a/docker/haproxy/config/haproxy.cfg +++ b/docker/haproxy/config/haproxy.cfg @@ -96,7 +96,7 @@ frontend http-in acl transport_http_acl path_beg /api/v1/ acl letsencrypt_http_acl path_beg /.well-known/acme-challenge/ acl tb_images_api_acl path_beg /api/images/ - acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /v3/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ + acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /v3/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ /.well-known/assetlinks.json /.well-known/apple-app-site-association redirect scheme https if !letsencrypt_http_acl !transport_http_acl { env(FORCE_HTTPS_REDIRECT) -m str true } @@ -124,7 +124,7 @@ frontend https_in acl transport_http_acl path_beg /api/v1/ acl tb_images_api_acl path_beg /api/images/ - acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /v3/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ + acl tb_api_acl path_beg /api/ /swagger /webjars /v2/ /v3/ /static/rulenode/ /oauth2/ /login/oauth2/ /static/widgets/ /.well-known/assetlinks.json /.well-known/apple-app-site-association use_backend tb-http-backend if transport_http_acl use_backend tb-images-api-backend if tb_images_api_acl diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/TransportHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/TransportHealthChecker.java index bee65d1f37..4b04f5d8de 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/TransportHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/transport/TransportHealthChecker.java @@ -145,7 +145,7 @@ public abstract class TransportHealthChecker { TbResource newResource = ResourceUtils.getResource("lwm2m/resource.json", TbResource.class); diff --git a/monitoring/src/main/resources/lwm2m/device_profile.json b/monitoring/src/main/resources/lwm2m/device_profile.json index cc78187938..c75fe9ce7a 100644 --- a/monitoring/src/main/resources/lwm2m/device_profile.json +++ b/monitoring/src/main/resources/lwm2m/device_profile.json @@ -12,14 +12,14 @@ "transportConfiguration": { "observeAttr": { "observe": [ - "/3_1.1/0/0" + "/3_1.0/0/0" ], "attribute": [], "telemetry": [ - "/3_1.1/0/0" + "/3_1.0/0/0" ], "keyName": { - "/3_1.1/0/0": "testData" + "/3_1.0/0/0": "testData" }, "attributeLwm2m": {} }, @@ -44,8 +44,7 @@ "clientOnlyObserveAfterConnect": 1, "fwUpdateStrategy": 1, "swUpdateStrategy": 1, - "powerMode": "DRX", - "compositeOperationsSupport": false + "powerMode": "DRX" }, "bootstrapServerUpdateEnable": false, "type": "LWM2M" diff --git a/monitoring/src/main/resources/lwm2m/models/test-model.xml b/monitoring/src/main/resources/lwm2m/models/test-model.xml index b13934531d..7b71b5e783 100644 --- a/monitoring/src/main/resources/lwm2m/models/test-model.xml +++ b/monitoring/src/main/resources/lwm2m/models/test-model.xml @@ -23,9 +23,9 @@ 3 - urn:oma:lwm2m:oma:3:1.1 + urn:oma:lwm2m:oma:3 1.1 - 1.1 + 1.0 Single Mandatory diff --git a/monitoring/src/main/resources/lwm2m/resource.json b/monitoring/src/main/resources/lwm2m/resource.json index 3f4ec5c3fe..dcc0182bdd 100644 --- a/monitoring/src/main/resources/lwm2m/resource.json +++ b/monitoring/src/main/resources/lwm2m/resource.json @@ -2,5 +2,5 @@ "title": "", "resourceType": "LWM2M_MODEL", "fileName": "test-model.xml", - "data": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCEtLQoKICAgIENvcHlyaWdodCDCqSAyMDE2LTIwMjIgVGhlIFRoaW5nc2JvYXJkIEF1dGhvcnMKCiAgICBMaWNlbnNlZCB1bmRlciB0aGUgQXBhY2hlIExpY2Vuc2UsIFZlcnNpb24gMi4wICh0aGUgIkxpY2Vuc2UiKTsKICAgIHlvdSBtYXkgbm90IHVzZSB0aGlzIGZpbGUgZXhjZXB0IGluIGNvbXBsaWFuY2Ugd2l0aCB0aGUgTGljZW5zZS4KICAgIFlvdSBtYXkgb2J0YWluIGEgY29weSBvZiB0aGUgTGljZW5zZSBhdAoKICAgICAgICBodHRwOi8vd3d3LmFwYWNoZS5vcmcvbGljZW5zZXMvTElDRU5TRS0yLjAKCiAgICBVbmxlc3MgcmVxdWlyZWQgYnkgYXBwbGljYWJsZSBsYXcgb3IgYWdyZWVkIHRvIGluIHdyaXRpbmcsIHNvZnR3YXJlCiAgICBkaXN0cmlidXRlZCB1bmRlciB0aGUgTGljZW5zZSBpcyBkaXN0cmlidXRlZCBvbiBhbiAiQVMgSVMiIEJBU0lTLAogICAgV0lUSE9VVCBXQVJSQU5USUVTIE9SIENPTkRJVElPTlMgT0YgQU5ZIEtJTkQsIGVpdGhlciBleHByZXNzIG9yIGltcGxpZWQuCiAgICBTZWUgdGhlIExpY2Vuc2UgZm9yIHRoZSBzcGVjaWZpYyBsYW5ndWFnZSBnb3Zlcm5pbmcgcGVybWlzc2lvbnMgYW5kCiAgICBsaW1pdGF0aW9ucyB1bmRlciB0aGUgTGljZW5zZS4KCi0tPgo8TFdNMk0geG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIKICAgICAgIHhzaTpub05hbWVzcGFjZVNjaGVtYUxvY2F0aW9uPSJodHRwOi8vd3d3Lm9wZW5tb2JpbGVhbGxpYW5jZS5vcmcvdGVjaC9wcm9maWxlcy9MV00yTS12MV8xLnhzZCI+CiAgICA8T2JqZWN0IE9iamVjdFR5cGU9Ik1PRGVmaW5pdGlvbiI+CiAgICAgICAgPE5hbWU+THdNMk0gTW9uaXRvcmluZzwvTmFtZT4KICAgICAgICA8RGVzY3JpcHRpb24xPgogICAgICAgICAgICA8IVtDREFUQVtdXT48L0Rlc2NyaXB0aW9uMT4KICAgICAgICA8T2JqZWN0SUQ+MzwvT2JqZWN0SUQ+CiAgICAgICAgPE9iamVjdFVSTj51cm46b21hOmx3bTJtOm9tYTozOjEuMTwvT2JqZWN0VVJOPgogICAgICAgIDxMV00yTVZlcnNpb24+MS4xPC9MV00yTVZlcnNpb24+CiAgICAgICAgPE9iamVjdFZlcnNpb24+MS4xPC9PYmplY3RWZXJzaW9uPgogICAgICAgIDxNdWx0aXBsZUluc3RhbmNlcz5TaW5nbGU8L011bHRpcGxlSW5zdGFuY2VzPgogICAgICAgIDxNYW5kYXRvcnk+TWFuZGF0b3J5PC9NYW5kYXRvcnk+CiAgICAgICAgPFJlc291cmNlcz4KICAgICAgICAgICAgPEl0ZW0gSUQ9IjAiPgogICAgICAgICAgICAgICAgPE5hbWU+VGVzdCBkYXRhPC9OYW1lPgogICAgICAgICAgICAgICAgPE9wZXJhdGlvbnM+UjwvT3BlcmF0aW9ucz4KICAgICAgICAgICAgICAgIDxNdWx0aXBsZUluc3RhbmNlcz5TaW5nbGU8L011bHRpcGxlSW5zdGFuY2VzPgogICAgICAgICAgICAgICAgPE1hbmRhdG9yeT5PcHRpb25hbDwvTWFuZGF0b3J5PgogICAgICAgICAgICAgICAgPFR5cGU+U3RyaW5nPC9UeXBlPgogICAgICAgICAgICAgICAgPFJhbmdlRW51bWVyYXRpb24+PC9SYW5nZUVudW1lcmF0aW9uPgogICAgICAgICAgICAgICAgPFVuaXRzPjwvVW5pdHM+CiAgICAgICAgICAgICAgICA8RGVzY3JpcHRpb24+PCFbQ0RBVEFbVGVzdCBkYXRhXV0+PC9EZXNjcmlwdGlvbj4KICAgICAgICAgICAgPC9JdGVtPgogICAgICAgIDwvUmVzb3VyY2VzPgogICAgICAgIDxEZXNjcmlwdGlvbjI+PC9EZXNjcmlwdGlvbjI+CiAgICA8L09iamVjdD4KPC9MV00yTT4K" + "data": "PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPCEtLQoKICAgIENvcHlyaWdodCDCqSAyMDE2LTIwMjQgVGhlIFRoaW5nc2JvYXJkIEF1dGhvcnMKCiAgICBMaWNlbnNlZCB1bmRlciB0aGUgQXBhY2hlIExpY2Vuc2UsIFZlcnNpb24gMi4wICh0aGUgIkxpY2Vuc2UiKTsKICAgIHlvdSBtYXkgbm90IHVzZSB0aGlzIGZpbGUgZXhjZXB0IGluIGNvbXBsaWFuY2Ugd2l0aCB0aGUgTGljZW5zZS4KICAgIFlvdSBtYXkgb2J0YWluIGEgY29weSBvZiB0aGUgTGljZW5zZSBhdAoKICAgICAgICBodHRwOi8vd3d3LmFwYWNoZS5vcmcvbGljZW5zZXMvTElDRU5TRS0yLjAKCiAgICBVbmxlc3MgcmVxdWlyZWQgYnkgYXBwbGljYWJsZSBsYXcgb3IgYWdyZWVkIHRvIGluIHdyaXRpbmcsIHNvZnR3YXJlCiAgICBkaXN0cmlidXRlZCB1bmRlciB0aGUgTGljZW5zZSBpcyBkaXN0cmlidXRlZCBvbiBhbiAiQVMgSVMiIEJBU0lTLAogICAgV0lUSE9VVCBXQVJSQU5USUVTIE9SIENPTkRJVElPTlMgT0YgQU5ZIEtJTkQsIGVpdGhlciBleHByZXNzIG9yIGltcGxpZWQuCiAgICBTZWUgdGhlIExpY2Vuc2UgZm9yIHRoZSBzcGVjaWZpYyBsYW5ndWFnZSBnb3Zlcm5pbmcgcGVybWlzc2lvbnMgYW5kCiAgICBsaW1pdGF0aW9ucyB1bmRlciB0aGUgTGljZW5zZS4KCi0tPgo8TFdNMk0geG1sbnM6eHNpPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxL1hNTFNjaGVtYS1pbnN0YW5jZSIKICAgICAgIHhzaTpub05hbWVzcGFjZVNjaGVtYUxvY2F0aW9uPSJodHRwOi8vd3d3Lm9wZW5tb2JpbGVhbGxpYW5jZS5vcmcvdGVjaC9wcm9maWxlcy9MV00yTS12MV8xLnhzZCI+CiAgICA8T2JqZWN0IE9iamVjdFR5cGU9Ik1PRGVmaW5pdGlvbiI+CiAgICAgICAgPE5hbWU+THdNMk0gTW9uaXRvcmluZzwvTmFtZT4KICAgICAgICA8RGVzY3JpcHRpb24xPgogICAgICAgICAgICA8IVtDREFUQVtdXT48L0Rlc2NyaXB0aW9uMT4KICAgICAgICA8T2JqZWN0SUQ+MzwvT2JqZWN0SUQ+CiAgICAgICAgPE9iamVjdFVSTj51cm46b21hOmx3bTJtOm9tYTozPC9PYmplY3RVUk4+CiAgICAgICAgPExXTTJNVmVyc2lvbj4xLjE8L0xXTTJNVmVyc2lvbj4KICAgICAgICA8T2JqZWN0VmVyc2lvbj4xLjA8L09iamVjdFZlcnNpb24+CiAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgPE1hbmRhdG9yeT5NYW5kYXRvcnk8L01hbmRhdG9yeT4KICAgICAgICA8UmVzb3VyY2VzPgogICAgICAgICAgICA8SXRlbSBJRD0iMCI+CiAgICAgICAgICAgICAgICA8TmFtZT5UZXN0IGRhdGE8L05hbWU+CiAgICAgICAgICAgICAgICA8T3BlcmF0aW9ucz5SPC9PcGVyYXRpb25zPgogICAgICAgICAgICAgICAgPE11bHRpcGxlSW5zdGFuY2VzPlNpbmdsZTwvTXVsdGlwbGVJbnN0YW5jZXM+CiAgICAgICAgICAgICAgICA8TWFuZGF0b3J5Pk9wdGlvbmFsPC9NYW5kYXRvcnk+CiAgICAgICAgICAgICAgICA8VHlwZT5TdHJpbmc8L1R5cGU+CiAgICAgICAgICAgICAgICA8UmFuZ2VFbnVtZXJhdGlvbj48L1JhbmdlRW51bWVyYXRpb24+CiAgICAgICAgICAgICAgICA8VW5pdHM+PC9Vbml0cz4KICAgICAgICAgICAgICAgIDxEZXNjcmlwdGlvbj48IVtDREFUQVtUZXN0IGRhdGFdXT48L0Rlc2NyaXB0aW9uPgogICAgICAgICAgICA8L0l0ZW0+CiAgICAgICAgPC9SZXNvdXJjZXM+CiAgICAgICAgPERlc2NyaXB0aW9uMj48L0Rlc2NyaXB0aW9uMj4KICAgIDwvT2JqZWN0Pgo8L0xXTTJNPgo=" } \ No newline at end of file diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index f1b7525438..4c81e0f31f 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -43,6 +43,11 @@ testcontainers test + + software.xdev + testcontainers-junit4-mock + test + org.zeroturnaround zt-exec @@ -63,11 +68,6 @@ spring-boot-starter-test test - - org.junit.vintage - junit-vintage-engine - test - org.testng testng diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractLwm2mClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractLwm2mClientTest.java index d0a8b53c32..c0ae8e9776 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractLwm2mClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractLwm2mClientTest.java @@ -19,7 +19,6 @@ import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; import org.eclipse.leshan.client.object.Security; import org.eclipse.leshan.core.util.Hex; -import org.junit.Assert; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.Device; @@ -141,7 +140,7 @@ public class AbstractLwm2mClientTest extends AbstractContainerTest{ log.warn("msa basicTestConnection update -> finishState: [{}] states: {}", finishState, lwM2MTestClient.getClientStates()); return lwM2MTestClient.getClientStates().contains(finishState) || lwM2MTestClient.getClientStates().contains(ON_UPDATE_SUCCESS); }); - Assert.assertTrue(lwM2MTestClient.getClientStates().containsAll(expectedStatusesRegistrationLwm2mSuccess)); + assertThat(lwM2MTestClient.getClientStates()).containsAll(expectedStatusesRegistrationLwm2mSuccess); } public LwM2MTestClient createNewClient(Security security, diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/LwM2MTestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/LwM2MTestClient.java index 9b1ab4bd22..c9f193704b 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/LwM2MTestClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/LwM2MTestClient.java @@ -50,7 +50,6 @@ import org.eclipse.leshan.core.request.BootstrapRequest; import org.eclipse.leshan.core.request.DeregisterRequest; import org.eclipse.leshan.core.request.RegisterRequest; import org.eclipse.leshan.core.request.UpdateRequest; -import org.junit.Assert; import org.thingsboard.server.msa.connectivity.lwm2m.Lwm2mTestHelper.LwM2MClientState; import java.io.IOException; @@ -63,6 +62,7 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.TimeUnit; +import static org.assertj.core.api.Assertions.assertThat; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CONNECTION_ID_LENGTH; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY; import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL; @@ -107,7 +107,7 @@ public class LwM2MTestClient { private Map clientDtlsCid; public void init(Security security, int clientPort) throws InvalidDDFFileException, IOException { - Assert.assertNull("client already initialized", leshanClient); + assertThat(leshanClient).as("client already initialized").isNull(); List models = new ArrayList<>(); for (String resourceName : resources) { diff --git a/msa/js-executor/api/utils.ts b/msa/js-executor/api/utils.ts index 963ea1ddae..c228d98315 100644 --- a/msa/js-executor/api/utils.ts +++ b/msa/js-executor/api/utils.ts @@ -62,3 +62,7 @@ export function parseJsErrorDetails(err: any): string | undefined { export function isNotUUID(candidate: string) { return candidate.length != 36 || !candidate.includes('-'); } + +export function isNotEmptyStr(value: any): boolean { + return typeof value === 'string' && value.trim().length > 0; +} diff --git a/msa/js-executor/config/custom-environment-variables.yml b/msa/js-executor/config/custom-environment-variables.yml index 1f5e5d711a..7c79eda0d1 100644 --- a/msa/js-executor/config/custom-environment-variables.yml +++ b/msa/js-executor/config/custom-environment-variables.yml @@ -38,6 +38,17 @@ kafka: topic_properties: "TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES" use_confluent_cloud: "TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD" client_id: "KAFKA_CLIENT_ID" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh + ssl: + # Enable or disable SSL for Kafka communication. + enabled: "TB_KAFKA_SSL_ENABLED" + # Path to the server certificate file. This file can hold the server certificate or a certificate chain and may include the server private key. + cert_file: "TB_KAFKA_SSL_PEM_CERTIFICATE" + # Optional: Path to the server certificate private key file. Required if the private key is not included in the server certificate file. + key_file: "TB_KAFKA_SSL_PEM_KEY" + # Optional: Password for the server certificate private key, if applicable. + key_password: "TB_KAFKA_SSL_PEM_KEY_PASSWORD" + # Optional: Path to a custom CA certificate file. Defaults to trusting well-known CAs curated by Mozilla. + ca_file: "TB_KAFKA_SSL_PEM_TRUSTS_CERTIFICATE" confluent: sasl: mechanism: "TB_QUEUE_KAFKA_CONFLUENT_SASL_MECHANISM" diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml index b340425a31..b2e84d0d46 100644 --- a/msa/js-executor/config/default.yml +++ b/msa/js-executor/config/default.yml @@ -38,6 +38,8 @@ kafka: topic_properties: "retention.ms:604800000;segment.bytes:26214400;retention.bytes:104857600;partitions:100;min.insync.replicas:1" use_confluent_cloud: false client_id: "kafkajs" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh + ssl: + enabled: false confluent: sasl: mechanism: "PLAIN" diff --git a/msa/js-executor/queue/kafkaTemplate.ts b/msa/js-executor/queue/kafkaTemplate.ts index 4ba3a1aee1..659d1fb8b2 100644 --- a/msa/js-executor/queue/kafkaTemplate.ts +++ b/msa/js-executor/queue/kafkaTemplate.ts @@ -15,6 +15,7 @@ /// import config from 'config'; +import fs from 'node:fs'; import { _logger, KafkaJsWinstonLogCreator } from '../config/logger'; import { JsInvokeMessageProcessor } from '../api/jsInvokeMessageProcessor' import { IQueue } from './queue.models'; @@ -29,8 +30,10 @@ import { Producer, TopicMessages } from 'kafkajs'; +import { isNotEmptyStr } from '../api/utils'; +import { KeyObject } from 'tls'; -import process, { kill, exit } from 'process'; +import process, { exit, kill } from 'process'; export class KafkaTemplate implements IQueue { @@ -64,6 +67,7 @@ export class KafkaTemplate implements IQueue { const queuePrefix: string = config.get('queue_prefix'); const requestTopic: string = queuePrefix ? queuePrefix + "." + config.get('request_topic') : config.get('request_topic'); const useConfluent = config.get('kafka.use_confluent_cloud'); + const enabledSsl = Boolean(config.get('kafka.ssl.enabled')); const groupId:string = queuePrefix ? queuePrefix + ".js-executor-group" : "js-executor-group"; this.logger.info('Kafka Bootstrap Servers: %s', kafkaBootstrapServers); this.logger.info('Kafka Requests Topic: %s', requestTopic); @@ -93,6 +97,31 @@ export class KafkaTemplate implements IQueue { kafkaConfig['ssl'] = true; } + if (enabledSsl) { + const certFilePath: string = config.has('kafka.ssl.cert_file') ? config.get('kafka.ssl.cert_file') : ''; + const keyFilePath: string = config.has('kafka.ssl.key_file') ? config.get('kafka.ssl.key_file') : ''; + const keyPassword: string = config.has('kafka.ssl.key_password') ? config.get('kafka.ssl.key_password') : ''; + const caFilePath: string = config.has('kafka.ssl.ca_file') ? config.get('kafka.ssl.ca_file') : ''; + + kafkaConfig.ssl = {}; + + if (isNotEmptyStr(certFilePath)) { + kafkaConfig.ssl.cert = fs.readFileSync(certFilePath, 'utf-8'); + } + + if (isNotEmptyStr(keyFilePath)) { + const keyConfig: KeyObject = {pem: fs.readFileSync(keyFilePath, 'utf-8')}; + if (isNotEmptyStr(keyPassword)) { + keyConfig.passphrase = keyPassword; + } + kafkaConfig.ssl.key = [keyConfig]; + } + + if (isNotEmptyStr(caFilePath)) { + kafkaConfig.ssl.ca = fs.readFileSync(caFilePath, 'utf-8'); + } + } + this.parseTopicProperties(); this.kafkaClient = new Kafka(kafkaConfig); @@ -213,6 +242,7 @@ export class KafkaTemplate implements IQueue { private createTopic(topic: string, partitions: number): Promise { return this.kafkaAdmin.createTopics({ + timeout: this.requestTimeout, topics: [{ topic: topic, numPartitions: partitions, diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index 34340ab08c..081ebd1d3c 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -75,11 +75,6 @@ spring-boot-starter-test test - - org.junit.vintage - junit-vintage-engine - test - org.awaitility awaitility diff --git a/pom.xml b/pom.xml index c06166e497..f75e782bef 100755 --- a/pom.xml +++ b/pom.xml @@ -148,6 +148,7 @@ 5.4.0 2.2 1.19.7 + 1.0.1 1.12 4.19.1 5.8.0 @@ -1476,6 +1477,12 @@ org.apache.httpcomponents httpclient ${apache-httpclient.version} + + + commons-logging + commons-logging + + org.apache.httpcomponents @@ -1943,6 +1950,12 @@ org.elasticsearch.client elasticsearch-rest-client ${elasticsearch.version} + + + commons-logging + commons-logging + + org.javadelight @@ -1968,11 +1981,23 @@ com.amazonaws aws-java-sdk-sqs ${aws.sdk.version} + + + commons-logging + commons-logging + + com.amazonaws aws-java-sdk-sns ${aws.sdk.version} + + + commons-logging + commons-logging + + com.google.cloud @@ -2102,6 +2127,10 @@ org.slf4j * + + commons-logging + commons-logging + com.github.spotbugs spotbugs-annotations @@ -2125,6 +2154,14 @@ + + + software.xdev + testcontainers-junit4-mock + ${testcontainers-junit4-mock.version} + test + org.zeroturnaround zt-exec @@ -2136,6 +2173,12 @@ mockserver-netty ${mock-server.version} test + + + commons-logging + commons-logging + + org.mock-server @@ -2168,6 +2211,12 @@ com.google.firebase firebase-admin ${firebase-admin.version} + + + commons-logging + commons-logging + + org.eclipse.jgit @@ -2203,6 +2252,12 @@ org.apache.xmlgraphics batik-transcoder ${apache-xmlgraphics.version} + + + commons-logging + commons-logging + + org.apache.xmlgraphics diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java index 4baec1c882..0b30d02842 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java @@ -15,6 +15,8 @@ */ package org.thingsboard.rule.engine.flow; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -22,10 +24,14 @@ import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.plugin.ComponentType; +import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; +import java.util.Optional; import java.util.UUID; @Slf4j @@ -33,11 +39,13 @@ import java.util.UUID; type = ComponentType.FLOW, name = "rule chain", configClazz = TbRuleChainInputNodeConfiguration.class, - nodeDescription = "transfers the message to another rule chain", - nodeDetails = "Allows to nest the rule chain similar to single rule node. " + - "The incoming message is forwarded to the input node of the specified target rule chain. " + - "The target rule chain may produce multiple labeled outputs. " + - "You may use the outputs to forward the results of processing to other rule nodes.", + version = 1, + nodeDescription = "Transfers the message to another rule chain", + nodeDetails = "The incoming message is forwarded to the input node of target rule chain. " + + "If 'Forward message to the originator's default rule chain' is enabled, " + + "then target rule chain might be resolved dynamically based on incoming message originator. " + + "In this case rule chain specified in the configuration will be used as fallback rule chain.

" + + "Output connections: Any connection(s) produced by output node(s) in the target rule chain.", uiResources = {"static/rulenode/rulenode-core-config.js"}, configDirective = "tbFlowNodeRuleChainInputConfig", relationTypes = {}, @@ -46,19 +54,57 @@ import java.util.UUID; ) public class TbRuleChainInputNode implements TbNode { - private TbRuleChainInputNodeConfiguration config; private RuleChainId ruleChainId; + private boolean forwardMsgToDefaultRuleChain; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { - this.config = TbNodeUtils.convert(configuration, TbRuleChainInputNodeConfiguration.class); - this.ruleChainId = new RuleChainId(UUID.fromString(config.getRuleChainId())); + TbRuleChainInputNodeConfiguration config = TbNodeUtils.convert(configuration, TbRuleChainInputNodeConfiguration.class); + if (config.getRuleChainId() == null) { + throw new TbNodeException("Rule chain must be set!", true); + } + UUID ruleChainUUID; + try { + ruleChainUUID = UUID.fromString(config.getRuleChainId()); + } catch (Exception e) { + throw new TbNodeException("Failed to parse rule chain id: " + config.getRuleChainId(), true); + } + ruleChainId = new RuleChainId(ruleChainUUID); ctx.checkTenantEntity(ruleChainId); + forwardMsgToDefaultRuleChain = config.isForwardMsgToDefaultRuleChain(); } @Override - public void onMsg(TbContext ctx, TbMsg msg) { - ctx.input(msg, ruleChainId); + public void onMsg(TbContext ctx, TbMsg msg) throws TbNodeException { + RuleChainId targetRuleChainId = forwardMsgToDefaultRuleChain ? + getOriginatorDefaultRuleChainId(ctx, msg).orElse(ruleChainId) : ruleChainId; + ctx.input(msg, targetRuleChainId); } + @Override + public TbPair upgrade(int fromVersion, JsonNode oldConfiguration) throws TbNodeException { + boolean hasChanges = false; + switch (fromVersion) { + case 0 -> { + if (!oldConfiguration.has("forwardMsgToDefaultRuleChain")) { + hasChanges = true; + ((ObjectNode) oldConfiguration).put("forwardMsgToDefaultRuleChain", false); + } + } + default -> { + } + } + return new TbPair<>(hasChanges, oldConfiguration); + } + + private Optional getOriginatorDefaultRuleChainId(TbContext ctx, TbMsg msg) { + return Optional.ofNullable( + switch (msg.getOriginator().getEntityType()) { + case DEVICE -> + ctx.getDeviceProfileCache().get(ctx.getTenantId(), (DeviceId) msg.getOriginator()).getDefaultRuleChainId(); + case ASSET -> + ctx.getAssetProfileCache().get(ctx.getTenantId(), (AssetId) msg.getOriginator()).getDefaultRuleChainId(); + default -> null; + }); + } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeConfiguration.java index 30c2a49821..84ddb779f7 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeConfiguration.java @@ -22,10 +22,13 @@ import org.thingsboard.rule.engine.api.NodeConfiguration; public class TbRuleChainInputNodeConfiguration implements NodeConfiguration { private String ruleChainId; + private boolean forwardMsgToDefaultRuleChain; @Override public TbRuleChainInputNodeConfiguration defaultConfiguration() { - return new TbRuleChainInputNodeConfiguration(); + TbRuleChainInputNodeConfiguration configuration = new TbRuleChainInputNodeConfiguration(); + configuration.setForwardMsgToDefaultRuleChain(false); + return configuration; } } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmState.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmState.java index 281fdcb16a..f03d1e46ba 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmState.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/profile/AlarmState.java @@ -244,27 +244,27 @@ class AlarmState { return null; } } else { - currentAlarm = new Alarm(); - currentAlarm.setType(alarmDefinition.getAlarmType()); - currentAlarm.setAcknowledged(false); - currentAlarm.setCleared(false); - currentAlarm.setSeverity(severity); + var newAlarm = new Alarm(); + newAlarm.setType(alarmDefinition.getAlarmType()); + newAlarm.setAcknowledged(false); + newAlarm.setCleared(false); + newAlarm.setSeverity(severity); long startTs = dataSnapshot.getTs(); if (startTs == 0L) { startTs = System.currentTimeMillis(); } - currentAlarm.setStartTs(startTs); - currentAlarm.setEndTs(currentAlarm.getStartTs()); - currentAlarm.setDetails(createDetails(ruleState)); - currentAlarm.setOriginator(originator); - currentAlarm.setTenantId(ctx.getTenantId()); - currentAlarm.setPropagate(alarmDefinition.isPropagate()); - currentAlarm.setPropagateToOwner(alarmDefinition.isPropagateToOwner()); - currentAlarm.setPropagateToTenant(alarmDefinition.isPropagateToTenant()); + newAlarm.setStartTs(startTs); + newAlarm.setEndTs(startTs); + newAlarm.setDetails(createDetails(ruleState)); + newAlarm.setOriginator(originator); + newAlarm.setTenantId(ctx.getTenantId()); + newAlarm.setPropagate(alarmDefinition.isPropagate()); + newAlarm.setPropagateToOwner(alarmDefinition.isPropagateToOwner()); + newAlarm.setPropagateToTenant(alarmDefinition.isPropagateToTenant()); if (alarmDefinition.getPropagateRelationTypes() != null) { - currentAlarm.setPropagateRelationTypes(alarmDefinition.getPropagateRelationTypes()); + newAlarm.setPropagateRelationTypes(alarmDefinition.getPropagateRelationTypes()); } - AlarmApiCallResult result = ctx.getAlarmService().createAlarm(AlarmCreateOrUpdateActiveRequest.fromAlarm(currentAlarm)); + AlarmApiCallResult result = ctx.getAlarmService().createAlarm(AlarmCreateOrUpdateActiveRequest.fromAlarm(newAlarm)); currentAlarm = result.getAlarm(); return TbAlarmResult.fromAlarmResult(result); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeTest.java new file mode 100644 index 0000000000..579ed7f035 --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeTest.java @@ -0,0 +1,299 @@ +/** + * 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.rule.engine.flow; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; +import org.thingsboard.rule.engine.api.RuleEngineAssetProfileCache; +import org.thingsboard.rule.engine.api.RuleEngineDeviceProfileCache; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNode; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; + +import java.util.UUID; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class TbRuleChainInputNodeTest extends AbstractRuleNodeUpgradeTest { + + private final TenantId TENANT_ID = new TenantId(UUID.fromString("4ba69ea5-6b27-42df-ab66-e7a727a67027")); + private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("97731954-2147-4176-8f1a-d14f1b73e4e6")); + private final AssetId ASSET_ID = new AssetId(UUID.fromString("841a47bd-4e8e-4ea5-88e6-420da0d70e51")); + + private TbRuleChainInputNode node; + private TbRuleChainInputNodeConfiguration config; + private TbNodeConfiguration nodeConfiguration; + + @Mock + private TbContext ctxMock; + @Mock + private RuleEngineDeviceProfileCache deviceProfileCacheMock; + @Mock + private RuleEngineAssetProfileCache assetProfileCacheMock; + + @BeforeEach + public void setUp() { + node = spy(new TbRuleChainInputNode()); + config = new TbRuleChainInputNodeConfiguration().defaultConfiguration(); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + } + + @ParameterizedTest + @MethodSource + public void givenValidConfig_whenInit_thenOk(String ruleChainIdStr, boolean forwardMsgToDefaultRuleChain) throws TbNodeException { + //GIVEN + config.setRuleChainId(ruleChainIdStr); + config.setForwardMsgToDefaultRuleChain(forwardMsgToDefaultRuleChain); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + //WHEN + assertThatCode(() -> node.init(ctxMock, nodeConfiguration)) + .doesNotThrowAnyException(); + + //THEN + verify(ctxMock).checkTenantEntity(new RuleChainId(UUID.fromString(ruleChainIdStr))); + } + + private static Stream givenValidConfig_whenInit_thenOk() { + return Stream.of( + Arguments.of("45bba7c4-04bf-419b-ae03-6ceb9724f10e", false), + Arguments.of("52d57e1b-70bb-480e-bcc4-6710e1dcc9d8", true) + ); + } + + @ParameterizedTest + @ValueSource(strings = {"91acbce0-079fdb", "", " ", "my test string"}) + public void givenInvalidRuleChainId_whenInit_thenThrowsException(String ruleChainIdStr) { + //GIVEN + config.setRuleChainId(ruleChainIdStr); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + //WHEN-THEN + Assertions.assertThatThrownBy(() -> node.init(ctxMock, nodeConfiguration)) + .isInstanceOf(TbNodeException.class) + .hasMessage("Failed to parse rule chain id: " + ruleChainIdStr); + } + + @Test + public void givenRuleChainIdIsNotSet_whenInit_thenThrowsException() { + assertThatThrownBy(() -> node.init(ctxMock, nodeConfiguration)) + .isInstanceOf(TbNodeException.class) + .hasMessage("Rule chain must be set!"); + } + + @Test + public void givenForwardMsgToDefaultIsTrue_whenOnMsg_thenShouldTransferToDeviceDefaultRuleChain() throws TbNodeException { + //GIVEN + DeviceProfile deviceProfile = new DeviceProfile(); + RuleChainId defaultRuleChainId = new RuleChainId(UUID.fromString("196e3cd5-68b8-421e-a0cf-1d44fa377cdf")); + deviceProfile.setDefaultRuleChainId(defaultRuleChainId); + + TbMsg msg = getMsg(DEVICE_ID); + + String ruleChainIdFromConfigStr = "acbc924f-7f95-4a9b-a854-e4822deb74c7"; + config.setRuleChainId(ruleChainIdFromConfigStr); + config.setForwardMsgToDefaultRuleChain(true); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + when(ctxMock.getDeviceProfileCache()).thenReturn(deviceProfileCacheMock); + when(deviceProfileCacheMock.get(any(TenantId.class), any(DeviceId.class))).thenReturn(deviceProfile); + + node.init(ctxMock, nodeConfiguration); + + //WHEN + node.onMsg(ctxMock, msg); + + //THEN + ArgumentCaptor ruleChainArgumentCaptor = ArgumentCaptor.forClass(RuleChainId.class); + verify(ctxMock).input(eq(msg), ruleChainArgumentCaptor.capture()); + RuleChainId expectedRuleChainId = ruleChainArgumentCaptor.getValue(); + assertThat(expectedRuleChainId).isEqualTo(defaultRuleChainId); + + RuleChainId ruleChainId = (RuleChainId) ReflectionTestUtils.getField(node, "ruleChainId"); + assertThat(ruleChainId).isEqualTo(new RuleChainId(UUID.fromString(ruleChainIdFromConfigStr))); + } + + @Test + public void givenForwardMsgToDefaultIsTrue_whenOnMsg_thenShouldTransferToAssetDefaultRuleChain() throws TbNodeException { + //GIVEN + AssetProfile assetProfile = new AssetProfile(); + RuleChainId defaultRuleChainId = new RuleChainId(UUID.fromString("f0a3cd58-980c-4730-a40c-8f59064d2065")); + assetProfile.setDefaultRuleChainId(defaultRuleChainId); + + TbMsg msg = getMsg(ASSET_ID); + + String ruleChainIdFromConfigStr = "56f1c0b8-1a00-4ce0-b3ab-a1416d7cc429"; + config.setRuleChainId(ruleChainIdFromConfigStr); + config.setForwardMsgToDefaultRuleChain(true); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + when(ctxMock.getAssetProfileCache()).thenReturn(assetProfileCacheMock); + when(assetProfileCacheMock.get(any(TenantId.class), any(AssetId.class))).thenReturn(assetProfile); + + node.init(ctxMock, nodeConfiguration); + + //WHEN + node.onMsg(ctxMock, msg); + + //THEN + ArgumentCaptor ruleChainArgumentCaptor = ArgumentCaptor.forClass(RuleChainId.class); + verify(ctxMock).input(eq(msg), ruleChainArgumentCaptor.capture()); + RuleChainId expectedRuleChainId = ruleChainArgumentCaptor.getValue(); + assertThat(expectedRuleChainId).isEqualTo(defaultRuleChainId); + + RuleChainId ruleChainId = (RuleChainId) ReflectionTestUtils.getField(node, "ruleChainId"); + assertThat(ruleChainId).isEqualTo(new RuleChainId(UUID.fromString(ruleChainIdFromConfigStr))); + } + + @Test + public void givenForwardMsgToDefaultIsTrueWithoutDeviceDefaultRuleChain_whenOnMsg_thenShouldTransferToRuleChainFromConfig() throws TbNodeException { + //GIVEN + DeviceProfile deviceProfile = new DeviceProfile(); + + TbMsg msg = getMsg(DEVICE_ID); + + String ruleChainIdFromConfigStr = "357c2785-e7cc-46a8-9797-957180dabdeb"; + RuleChainId ruleChainIdFromConfig = new RuleChainId(UUID.fromString(ruleChainIdFromConfigStr)); + config.setRuleChainId(ruleChainIdFromConfigStr); + config.setForwardMsgToDefaultRuleChain(true); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + when(ctxMock.getDeviceProfileCache()).thenReturn(deviceProfileCacheMock); + when(deviceProfileCacheMock.get(any(TenantId.class), any(DeviceId.class))).thenReturn(deviceProfile); + + node.init(ctxMock, nodeConfiguration); + + //WHEN + node.onMsg(ctxMock, msg); + + //THEN + ArgumentCaptor ruleChainArgumentCaptor = ArgumentCaptor.forClass(RuleChainId.class); + verify(ctxMock).input(eq(msg), ruleChainArgumentCaptor.capture()); + assertThat(ruleChainArgumentCaptor.getValue()).isEqualTo(ruleChainIdFromConfig); + } + + @Test + public void givenForwardMsgToDefaultIsTrueWithoutAssetDefaultRuleChain_whenOnMsg_thenShouldTransferToRuleChainFromConfig() throws TbNodeException { + //GIVEN + AssetProfile assetProfile = new AssetProfile(); + + TbMsg msg = getMsg(ASSET_ID); + + String ruleChainIdFromConfigStr = "12883c3d-c10b-4d5b-b606-a59385a920bc"; + RuleChainId ruleChainIdFromConfig = new RuleChainId(UUID.fromString(ruleChainIdFromConfigStr)); + config.setRuleChainId(ruleChainIdFromConfigStr); + config.setForwardMsgToDefaultRuleChain(true); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + when(ctxMock.getAssetProfileCache()).thenReturn(assetProfileCacheMock); + when(assetProfileCacheMock.get(any(TenantId.class), any(AssetId.class))).thenReturn(assetProfile); + + node.init(ctxMock, nodeConfiguration); + + //WHEN + node.onMsg(ctxMock, msg); + + //THEN + ArgumentCaptor ruleChainArgumentCaptor = ArgumentCaptor.forClass(RuleChainId.class); + verify(ctxMock).input(eq(msg), ruleChainArgumentCaptor.capture()); + assertThat(ruleChainArgumentCaptor.getValue()).isEqualTo(ruleChainIdFromConfig); + } + + @Test + public void givenRuleChainInConfig_whenOnMsg_thenShouldTransferToRuleChainFromConfig() throws TbNodeException { + //GIVEN + String ruleChainIdFromConfigStr = "3c02c8b3-645c-4e67-aac5-f984f59471d1"; + RuleChainId ruleChainIdFromConfig = new RuleChainId(UUID.fromString(ruleChainIdFromConfigStr)); + + TbMsg msg = getMsg(DEVICE_ID); + + config.setRuleChainId(ruleChainIdFromConfigStr); + config.setForwardMsgToDefaultRuleChain(false); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + node.init(ctxMock, nodeConfiguration); + + //WHEN + node.onMsg(ctxMock, msg); + + //THEN + ArgumentCaptor ruleChainArgumentCaptor = ArgumentCaptor.forClass(RuleChainId.class); + verify(ctxMock).input(eq(msg), ruleChainArgumentCaptor.capture()); + assertThat(ruleChainArgumentCaptor.getValue()).isEqualTo(ruleChainIdFromConfig); + } + + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { + return Stream.of( + //config for version 0 + Arguments.of(0, + "{\"ruleChainId\": null}", + true, + "{\"ruleChainId\": null, \"forwardMsgToDefaultRuleChain\": false}" + ), + //config for version 1 with upgrade from version 0 + Arguments.of(1, + "{\"ruleChainId\": null, \"forwardMsgToDefaultRuleChain\": false}", + false, + "{\"ruleChainId\": null, \"forwardMsgToDefaultRuleChain\": false}" + ) + ); + } + + @Override + protected TbNode getTestNode() { + return node; + } + + private TbMsg getMsg(EntityId entityId) { + return TbMsg.newMsg(TbMsgType.POST_TELEMETRY_REQUEST, entityId, TbMsgMetaData.EMPTY, TbMsg.EMPTY_STRING); + } +} diff --git a/ui-ngx/src/app/core/api/alarm-data-subscription.ts b/ui-ngx/src/app/core/api/alarm-data-subscription.ts index 80b3c58ab8..6b1e90ac63 100644 --- a/ui-ngx/src/app/core/api/alarm-data-subscription.ts +++ b/ui-ngx/src/app/core/api/alarm-data-subscription.ts @@ -128,6 +128,9 @@ export class AlarmDataSubscription { this.alarmDataCommand.query.pageLink.startTs = this.subsTw.fixedWindow.startTimeMs; this.alarmDataCommand.query.pageLink.endTs = this.subsTw.fixedWindow.endTimeMs; } else { + if (this.subsTw.startTs) { + this.alarmDataCommand.query.pageLink.startTs = this.subsTw.startTs; + } this.alarmDataCommand.query.pageLink.timeWindow = this.subsTw.realtimeWindowMs; } diff --git a/ui-ngx/src/app/core/api/data-aggregator.ts b/ui-ngx/src/app/core/api/data-aggregator.ts index 5bce9115e8..d437bd73d0 100644 --- a/ui-ngx/src/app/core/api/data-aggregator.ts +++ b/ui-ngx/src/app/core/api/data-aggregator.ts @@ -48,7 +48,8 @@ class AggDataMap { constructor( private subsTw: SubscriptionTimewindow, - private endTs: number + private endTs: number, + private aggType: AggregationType ){}; set(ts: number, data: AggData) { @@ -78,7 +79,7 @@ class AggDataMap { } calculateAggInterval(timestamp: number): [number, number] { - return calculateAggIntervalWithSubscriptionTimeWindow(this.subsTw, this.endTs, timestamp); + return calculateAggIntervalWithSubscriptionTimeWindow(this.subsTw, this.endTs, timestamp, this.aggType); } updateLastInterval(endTs: number) { @@ -87,7 +88,7 @@ class AggDataMap { const lastTs = this.map.maxKey(); if (lastTs) { const data = this.map.get(lastTs); - const interval = calculateAggIntervalWithSubscriptionTimeWindow(this.subsTw, endTs, data.ts); + const interval = calculateAggIntervalWithSubscriptionTimeWindow(this.subsTw, endTs, data.ts, this.aggType); data.interval = interval; data.ts = interval[0] + Math.floor((interval[1] - interval[0]) / 2); } @@ -415,7 +416,7 @@ export class DataAggregator { const noAggregation = aggType === AggregationType.NONE; let aggKeyData = aggregationMap.aggMap[id]; if (!aggKeyData) { - aggKeyData = new AggDataMap(this.subsTw, this.endTs); + aggKeyData = new AggDataMap(this.subsTw, this.endTs, aggType); aggregationMap.aggMap[id] = aggKeyData; } const keyData = data[id]; @@ -449,7 +450,7 @@ export class DataAggregator { const noAggregation = aggType === AggregationType.NONE; let aggKeyData = this.aggregationMap.aggMap[id]; if (!aggKeyData) { - aggKeyData = new AggDataMap(this.subsTw, this.endTs); + aggKeyData = new AggDataMap(this.subsTw, this.endTs, aggType); this.aggregationMap.aggMap[id] = aggKeyData; } const keyData = data[id]; diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 7f76f25854..b73847ef83 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -24,6 +24,7 @@ export interface SysParamsState { hasRepository: boolean; tbelEnabled: boolean; persistDeviceStateToTelemetry: boolean; + mobileQrEnabled: boolean; userSettings: UserSettings; maxResourceSize: number; } diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index f755438600..cc9d01532a 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -29,6 +29,7 @@ const emptyUserAuthState: AuthPayload = { hasRepository: false, tbelEnabled: false, persistDeviceStateToTelemetry: false, + mobileQrEnabled: false, maxResourceSize: 0, userSettings: initialUserSettings }; diff --git a/ui-ngx/src/app/core/auth/auth.selectors.ts b/ui-ngx/src/app/core/auth/auth.selectors.ts index 0cb13c9865..2d84955936 100644 --- a/ui-ngx/src/app/core/auth/auth.selectors.ts +++ b/ui-ngx/src/app/core/auth/auth.selectors.ts @@ -78,6 +78,17 @@ export const selectPersistDeviceStateToTelemetry = createSelector( (state: AuthState) => state.persistDeviceStateToTelemetry ); +export const selectMobileQrEnabled = createSelector( + selectAuthState, + (state: AuthState) => state.mobileQrEnabled +); + +export const selectHomeDashboardParams = createSelector( + selectPersistDeviceStateToTelemetry, + selectMobileQrEnabled, + (persistDeviceStateToTelemetry, mobileQrEnabled) => ({persistDeviceStateToTelemetry, mobileQrEnabled}) +); + export const selectUserSettings = createSelector( selectAuthState, (state: AuthState) => state.userSettings diff --git a/ui-ngx/src/app/core/http/mobile-app.service.ts b/ui-ngx/src/app/core/http/mobile-app.service.ts new file mode 100644 index 0000000000..7b108e487c --- /dev/null +++ b/ui-ngx/src/app/core/http/mobile-app.service.ts @@ -0,0 +1,45 @@ +/// +/// 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. +/// + +import { HttpClient } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; +import { Observable } from 'rxjs'; +import { MobileAppSettings } from '@shared/models/mobile-app.models'; + +@Injectable({ + providedIn: 'root' +}) +export class MobileAppService { + + constructor( + private http: HttpClient + ) { + } + + public getMobileAppSettings(config?: RequestConfig): Observable { + return this.http.get(`/api/mobile/app/settings`, defaultHttpOptionsFromConfig(config)); + } + + public saveMobileAppSettings(mobileAppSettings: MobileAppSettings, config?: RequestConfig): Observable { + return this.http.post(`/api/mobile/app/settings`, mobileAppSettings, defaultHttpOptionsFromConfig(config)); + } + + public getMobileAppDeepLink(config?: RequestConfig): Observable { + return this.http.get(`/api/mobile/deepLink`, defaultHttpOptionsFromConfig(config)); + } + +} diff --git a/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts b/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts index 00bc0c3e64..8414e6ff0e 100644 --- a/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts +++ b/ui-ngx/src/app/core/services/dynamic-component-factory.service.ts @@ -17,7 +17,6 @@ import { Compiler, Component, - ComponentFactory, Injectable, Injector, NgModule, @@ -26,9 +25,9 @@ import { Type, ɵresetCompiledComponents } from '@angular/core'; -import { from, Observable } from 'rxjs'; +import { from, Observable, of } from 'rxjs'; import { CommonModule } from '@angular/common'; -import { catchError, map, mergeMap } from 'rxjs/operators'; +import { mergeMap } from 'rxjs/operators'; @NgModule() export abstract class DynamicComponentModule implements OnDestroy { @@ -79,33 +78,32 @@ export class DynamicComponentFactoryService { declarations: [comp], imports: moduleImports })(class DynamicComponentInstanceModule extends DynamicComponentModule {}); - return from(this.compiler.compileModuleAsync(dynamicComponentInstanceModule)).pipe( - map((module) => { - let moduleRef: NgModuleRef; - try { - moduleRef = module.create(this.injector); - } catch (e) { - this.compiler.clearCacheFor(module.moduleType); - throw e; - } - this.dynamicComponentModulesMap.set(comp, { - moduleRef, - moduleType: module.moduleType - }); - return { - componentType: comp, - componentModuleRef: moduleRef - }; - }), - catchError((error) => { - if (compileAttempt === 1) { - ɵresetCompiledComponents(); - return this.createDynamicComponent(componentType, template, modules, preserveWhitespaces, ++compileAttempt, styles); - } else { - throw error; - } - }) - ); + try { + const module = this.compiler.compileModuleSync(dynamicComponentInstanceModule); + let moduleRef: NgModuleRef; + try { + moduleRef = module.create(this.injector); + } catch (e) { + this.compiler.clearCacheFor(module.moduleType); + throw e; + } + this.dynamicComponentModulesMap.set(comp, { + moduleRef, + moduleType: module.moduleType + }); + return of( { + componentType: comp, + componentModuleRef: moduleRef + }); + } catch (error) { + if (compileAttempt === 1) { + ɵresetCompiledComponents(); + return this.createDynamicComponent(componentType, template, modules, preserveWhitespaces, ++compileAttempt, styles); + } else { + console.error(error); + throw error; + } + } }) ); } diff --git a/ui-ngx/src/app/core/services/menu.service.ts b/ui-ngx/src/app/core/services/menu.service.ts index 1f2d76a8b8..ffb5b5f0a6 100644 --- a/ui-ngx/src/app/core/services/menu.service.ts +++ b/ui-ngx/src/app/core/services/menu.service.ts @@ -245,6 +245,14 @@ export class MenuService { path: '/settings/queues', icon: 'swap_calls' }, + { + id: 'mobile_app_settings', + name: 'admin.mobile-app.mobile-app', + fullName: 'admin.mobile-app.mobile-app', + type: 'link', + path: '/settings/mobile-app', + icon: 'smartphone' + } ] }, { diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index 70b04ee4eb..097c3b8d49 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -386,8 +386,7 @@ export class UtilsService { this.window.performance.now() : Date.now(); } - public getQueryParam(name: string): string { - const url = this.window.location.href; + public getQueryParam(name: string, url = this.window.location.href): string { name = name.replace(/[\[\]]/g, '\\$&'); const regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'); const results = regex.exec(url); diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html index 4dc332afa1..22920a609c 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html @@ -91,26 +91,16 @@ - - - - - - - - - - - - - - - - - - - - +
+ device-profile.lwm2m.default-object-id + + + + {{ objectIDVerTranslationMap.get(objectIDVer) | translate }} + + + +
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts index 6d4fe85fd5..fbc72d9498 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts @@ -40,9 +40,11 @@ import { ObjectLwM2M, OBSERVE, PowerMode, + ObjectIDVer, RESOURCES, ServerSecurityConfig, - TELEMETRY + TELEMETRY, + ObjectIDVerTranslationMap } from './lwm2m-profile-config.models'; import { DeviceProfileService } from '@core/http/device-profile.service'; import { deepClone, isDefinedAndNotNull, isEmpty, isUndefined } from '@core/utils'; @@ -80,6 +82,10 @@ export class Lwm2mDeviceProfileTransportConfigurationComponent implements Contro lwm2mDeviceProfileFormGroup: UntypedFormGroup; configurationValue: Lwm2mProfileConfigModels; + + objectIDVers = Object.values(ObjectIDVer) as ObjectIDVer[]; + objectIDVerTranslationMap = ObjectIDVerTranslationMap; + sortFunction: (key: string, value: object) => object; get required(): boolean { @@ -116,7 +122,8 @@ export class Lwm2mDeviceProfileTransportConfigurationComponent implements Contro powerMode: [PowerMode.DRX, Validators.required], edrxCycle: [{disabled: true, value: 0}, Validators.required], psmActivityTimer: [{disabled: true, value: 0}, Validators.required], - pagingTransmissionWindow: [{disabled: true, value: 0}, Validators.required] + pagingTransmissionWindow: [{disabled: true, value: 0}, Validators.required], + defaultObjectIDVer: [ObjectIDVer.V1_0, Validators.required] }) }); @@ -274,7 +281,8 @@ export class Lwm2mDeviceProfileTransportConfigurationComponent implements Contro edrxCycle: this.configurationValue.clientLwM2mSettings.edrxCycle || DEFAULT_EDRX_CYCLE, pagingTransmissionWindow: this.configurationValue.clientLwM2mSettings.pagingTransmissionWindow || DEFAULT_PAGING_TRANSMISSION_WINDOW, - psmActivityTimer: this.configurationValue.clientLwM2mSettings.psmActivityTimer || DEFAULT_PSM_ACTIVITY_TIMER + psmActivityTimer: this.configurationValue.clientLwM2mSettings.psmActivityTimer || DEFAULT_PSM_ACTIVITY_TIMER, + defaultObjectIDVer: this.configurationValue.clientLwM2mSettings.defaultObjectIDVer || ObjectIDVer.V1_0 } }, {emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-profile-config.models.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-profile-config.models.ts index e5c292ef0a..8c1bb56a49 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-profile-config.models.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-profile-config.models.ts @@ -122,6 +122,18 @@ export const PowerModeTranslationMap = new Map( ] ); +export enum ObjectIDVer { + V1_0 = '1.0', + V1_1 = '1.1' +} + +export const ObjectIDVerTranslationMap = new Map( + [ + [ObjectIDVer.V1_0, 'device-profile.lwm2m.default-object-id-ver.v1-0'], + [ObjectIDVer.V1_1, 'device-profile.lwm2m.default-object-id-ver.v1-1'] + ] +); + export interface ServerSecurityConfig { host?: string; port?: number; @@ -163,7 +175,7 @@ export interface ClientLwM2mSettings { edrxCycle?: number; pagingTransmissionWindow?: number; psmActivityTimer?: number; - compositeOperationsSupport: boolean; + defaultObjectIDVer: ObjectIDVer; } export interface ObservableAttributes { @@ -190,7 +202,7 @@ export function getDefaultProfileClientLwM2mSettingsConfig(): ClientLwM2mSetting fwUpdateStrategy: 1, swUpdateStrategy: 1, powerMode: PowerMode.DRX, - compositeOperationsSupport: false + defaultObjectIDVer: ObjectIDVer.V1_0 }; } diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index ef024fe0a2..91f96c7aba 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -516,6 +516,18 @@ [type]="rateLimitsType.DEVICE_TELEMETRY_MESSAGES"> +
+ + +
+
+
+ + +
+
@@ -531,6 +543,12 @@ [type]="rateLimitsType.DEVICE_TELEMETRY_DATA_POINTS"> +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index c13eed9c03..2b32504839 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -74,6 +74,9 @@ export class DefaultTenantProfileConfigurationComponent implements ControlValueA transportDeviceMsgRateLimit: [null, []], transportDeviceTelemetryMsgRateLimit: [null, []], transportDeviceTelemetryDataPointsRateLimit: [null, []], + transportGatewayMsgRateLimit: [null, []], + transportGatewayTelemetryMsgRateLimit: [null, []], + transportGatewayTelemetryDataPointsRateLimit: [null, []], tenantEntityExportRateLimit: [null, []], tenantEntityImportRateLimit: [null, []], tenantNotificationRequestsRateLimit: [null, []], diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts index ee13ea9b23..a04336e15e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.models.ts @@ -26,6 +26,9 @@ export enum RateLimitsType { DEVICE_TELEMETRY_MESSAGES = 'DEVICE_TELEMETRY_MESSAGES', DEVICE_TELEMETRY_DATA_POINTS = 'DEVICE_TELEMETRY_DATA_POINTS', TENANT_MESSAGES = 'TENANT_MESSAGES', + GATEWAY_MESSAGES = 'GATEWAY_MESSAGES', + GATEWAY_TELEMETRY_MESSAGES = 'GATEWAY_TELEMETRY_MESSAGES', + GATEWAY_TELEMETRY_DATA_POINTS = 'GATEWAY_TELEMETRY_DATA_POINTS', TENANT_TELEMETRY_MESSAGES = 'TENANT_TELEMETRY_MESSAGES', TENANT_TELEMETRY_DATA_POINTS = 'TENANT_TELEMETRY_DATA_POINTS', TENANT_SERVER_REST_LIMITS_CONFIGURATION = 'TENANT_SERVER_REST_LIMITS_CONFIGURATION', @@ -50,6 +53,9 @@ export const rateLimitsLabelTranslationMap = new Map( [RateLimitsType.DEVICE_MESSAGES, 'tenant-profile.rate-limits.transport-device-msg'], [RateLimitsType.DEVICE_TELEMETRY_MESSAGES, 'tenant-profile.rate-limits.transport-device-telemetry-msg'], [RateLimitsType.DEVICE_TELEMETRY_DATA_POINTS, 'tenant-profile.rate-limits.transport-device-telemetry-data-points'], + [RateLimitsType.GATEWAY_MESSAGES, 'tenant-profile.rate-limits.transport-gateway-msg'], + [RateLimitsType.GATEWAY_TELEMETRY_MESSAGES, 'tenant-profile.rate-limits.transport-gateway-telemetry-msg'], + [RateLimitsType.GATEWAY_TELEMETRY_DATA_POINTS, 'tenant-profile.rate-limits.transport-gateway-telemetry-data-points'], [RateLimitsType.TENANT_SERVER_REST_LIMITS_CONFIGURATION, 'tenant-profile.rest-requests-for-tenant'], [RateLimitsType.CUSTOMER_SERVER_REST_LIMITS_CONFIGURATION, 'tenant-profile.customer-rest-limits'], [RateLimitsType.WS_UPDATE_PER_SESSION_RATE_LIMIT, 'tenant-profile.ws-limit-updates-per-session'], @@ -74,6 +80,9 @@ export const rateLimitsDialogTitleTranslationMap = new Map Array; + fetchCellClickColumns: () => Array; } export interface WidgetActionsData { diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html index f75b2e44e7..0330ad5621 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html @@ -50,6 +50,27 @@
+
+
{{'widget-config.column-index' | translate}}*
+ + + + {{ getCellClickColumnInfo($index, column) }} + + + + warning + + +
{{'widget-config.action-name' | translate}}*
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts index 2c7f94b8d5..230811fbd1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, Inject, OnDestroy, OnInit, SkipSelf } from '@angular/core'; +import { Component, Inject, OnDestroy, OnInit, SkipSelf, ViewChild } from '@angular/core'; import { ErrorStateMatcher } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; @@ -40,6 +40,7 @@ import { import { UtilsService } from '@core/services/utils.service'; import { actionDescriptorToAction, + CellClickColumnInfo, defaultWidgetAction, WidgetActionSource, widgetType @@ -47,6 +48,9 @@ import { import { takeUntil } from 'rxjs/operators'; import { CustomActionEditorCompleter } from '@home/components/widget/lib/settings/common/action/custom-action.models'; import { WidgetService } from '@core/http/widget.service'; +import { isDefinedAndNotNull, isNotEmptyStr } from '@core/utils'; +import { MatSelect } from '@angular/material/select'; +import { TranslateService } from '@ngx-translate/core'; export interface WidgetActionDialogData { isAdd: boolean; @@ -78,6 +82,12 @@ export class WidgetActionDialogComponent extends DialogComponent = []; + usedCellClickColumns: Array = []; + + @ViewChild('columnIndexSelect') columnIndexSelect: MatSelect; + columnIndexPlaceholderText = this.translate.instant('widget-config.select-column-index'); + constructor(protected store: Store, protected router: Router, private utils: UtilsService, @@ -85,7 +95,8 @@ export class WidgetActionDialogComponent extends DialogComponent, - public fb: FormBuilder) { + public fb: FormBuilder, + private translate: TranslateService) { super(store, router, dialogRef); this.isAdd = data.isAdd; if (this.isAdd) { @@ -99,11 +110,15 @@ export class WidgetActionDialogComponent extends DialogComponent { + ).subscribe((value) => { this.widgetActionFormGroup.get('name').updateValueAndValidity(); this.updateShowWidgetActionForm(); + if (value === 'cellClick') { + this.widgetActionFormGroup.get('columnIndex').setValidators([Validators.required]); + this.getCellClickColumnsInfo(); + } else { + this.widgetActionFormGroup.get('columnIndex').clearValidators(); + } + this.widgetActionFormGroup.get('columnIndex').updateValueAndValidity(); }); this.widgetActionFormGroup.get('useShowWidgetActionFunction').valueChanges.pipe( takeUntil(this.destroy$) ).subscribe(() => { this.updateShowWidgetActionForm(); }); + setTimeout(() => { + if (this.action?.actionSourceId === 'cellClick' && isDefinedAndNotNull(this.action.columnIndex) && + this.widgetActionFormGroup.get('columnIndex').value === null) { + this.columnIndexPlaceholderText = `${this.action.columnIndex} (${this.translate.instant('widget-config.not-set')})`; + this.columnIndexSelect.focus(); + } + }); } ngOnDestroy() { @@ -155,6 +184,22 @@ export class WidgetActionDialogComponent extends DialogComponent { + const actionColumn = this.configuredColumns[action.columnIndex]; + if (actionColumn && action.columnIndex !== this.action.columnIndex) { + this.usedCellClickColumns.push(action.columnIndex); + } + }); + } + } + private validateActionName(): ValidatorFn { return (c: FormControl) => { const newName = c.value; @@ -193,6 +238,10 @@ export class WidgetActionDialogComponent extends DialogComponent - + {{ column.title }} + [ngStyle]="cellStyle(alarm, column, row)" + (click)="onCellClick($event, alarm, column, $index)" + [class.tb-pointer]="columnHasCellClick($index)"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts index b0a8fafd08..1d1aa533cd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts @@ -204,6 +204,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, private columnWidth: {[key: string]: string} = {}; private columnDefaultVisibility: {[key: string]: boolean} = {}; private columnSelectionAvailability: {[key: string]: boolean} = {}; + private columnsWithCellClick: Array = []; private rowStylesInfo: RowStyleInfo; @@ -358,6 +359,7 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, this.enableStickyAction = isDefined(this.settings.enableStickyAction) ? this.settings.enableStickyAction : false; this.showCellActionsMenu = isDefined(this.settings.showCellActionsMenu) ? this.settings.showCellActionsMenu : true; this.columnDisplayAction.show = isDefined(this.settings.enableSelectColumnDisplay) ? this.settings.enableSelectColumnDisplay : true; + this.columnsWithCellClick = this.ctx.actionsApi.getActionDescriptors('cellClick').map(action => action.columnIndex); let enableFilter; if (isDefined(this.settings.enableFilter)) { enableFilter = this.settings.enableFilter; @@ -794,6 +796,33 @@ export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, return res; } + public onCellClick($event: Event, alarm: AlarmDataInfo, key: EntityColumn, columnIndex: number) { + this.alarmsDatasource.toggleCurrentAlarm(alarm); + const descriptors = this.ctx.actionsApi.getActionDescriptors('cellClick'); + let descriptor; + if (descriptors.length) { + descriptor = descriptors.find(desc => desc.columnIndex === columnIndex); + } + if ($event && descriptor) { + $event.stopPropagation(); + let entityId; + let entityName; + let entityLabel; + if (alarm && alarm.originator) { + entityId = alarm.originator; + entityName = alarm.entityName; + entityLabel = alarm.entityLabel; + } + this.ctx.actionsApi.handleWidgetAction($event, descriptor, entityId, entityName, {alarm, key}, entityLabel); + } + } + + public columnHasCellClick(columnIndex: number) { + if (this.columnsWithCellClick.length) { + return this.columnsWithCellClick.includes(columnIndex); + } + } + public onRowClick($event: Event, alarm: AlarmDataInfo) { if ($event) { $event.stopPropagation(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts new file mode 100644 index 0000000000..1b280d68c1 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/mobile-app-qr-code-widget.models.ts @@ -0,0 +1,47 @@ +/// +/// 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. +/// + +import { + AndroidConfig, + BadgePosition, + BadgeStyle, + IosConfig, + QRCodeConfig +} from '@shared/models/mobile-app.models'; + +export interface MobileAppQrCodeWidgetSettings { + useSystemSettings: boolean; + androidConfig: Pick; + iosConfig: Pick; + qrCodeConfig: Omit; +} + +export const mobileAppQrCodeWidgetDefaultSettings: MobileAppQrCodeWidgetSettings = { + useSystemSettings: true, + androidConfig: { + enabled: true + }, + iosConfig: { + enabled: true + }, + qrCodeConfig: { + badgeEnabled: true, + badgeStyle: BadgeStyle.ORIGINAL, + badgePosition: BadgePosition.RIGHT, + qrCodeLabelEnabled: true, + qrCodeLabel: 'Scan to connect or download mobile app' + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.html index 22e79fd526..36d58742a6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/entity/entities-table-widget.component.html @@ -40,12 +40,14 @@
- + {{ column.title }} - - + + +
+
+
gateway.security
+ + + {{ SecurityTypeTranslationsMap.get(type) | translate }} + + +
+ + +
+
gateway.username
+
+ + + + warning + + +
+
+
+
gateway.password
+
+ + +
+ +
+
+
+
+
+ +
{{ 'gateway.path-hint' | translate }}
+
+
gateway.CA-certificate-path
+
+ + + +
+
+
+
gateway.private-key-path
+
+ + + +
+
+
+
gateway.client-cert-path
+
+ + + +
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.scss new file mode 100644 index 0000000000..416f368279 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.scss @@ -0,0 +1,20 @@ +/** + * 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. + */ +:host { + width: 100%; + height: 100%; + display: block; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.ts new file mode 100644 index 0000000000..655545e5fb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/broker-security.component.ts @@ -0,0 +1,165 @@ +/// +/// 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. +/// + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + NgZone, + OnDestroy, + ViewContainerRef +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { Subject } from 'rxjs'; +import { Overlay } from '@angular/cdk/overlay'; +import { UtilsService } from '@core/services/utils.service'; +import { EntityService } from '@core/http/entity.service'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { + BrokerSecurityType, + BrokerSecurityTypeTranslationsMap, + noLeadTrailSpacesRegex +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'tb-broker-security', + templateUrl: './broker-security.component.html', + styleUrls: ['./broker-security.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => BrokerSecurityComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => BrokerSecurityComponent), + multi: true + } + ] +}) +export class BrokerSecurityComponent extends PageComponent implements ControlValueAccessor, Validator, OnDestroy { + + BrokerSecurityType = BrokerSecurityType; + + securityTypes = Object.values(BrokerSecurityType); + + SecurityTypeTranslationsMap = BrokerSecurityTypeTranslationsMap; + + securityFormGroup: UntypedFormGroup; + + private destroy$ = new Subject(); + private propagateChange = (v: any) => {}; + + constructor(protected store: Store, + public translate: TranslateService, + public dialog: MatDialog, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private dialogService: DialogService, + private entityService: EntityService, + private utils: UtilsService, + private zone: NgZone, + private cd: ChangeDetectorRef, + private elementRef: ElementRef, + private fb: FormBuilder) { + super(store); + this.securityFormGroup = this.fb.group({ + type: [BrokerSecurityType.ANONYMOUS, []], + username: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + password: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + pathToCACert: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + pathToPrivateKey: ['', [Validators.pattern(noLeadTrailSpacesRegex)]], + pathToClientCert: ['', [Validators.pattern(noLeadTrailSpacesRegex)]] + }); + this.securityFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + this.updateView(value); + }); + this.securityFormGroup.get('type').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((type) => { + this.updateValidators(type); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + super.ngOnDestroy(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void {} + + writeValue(deviceInfo: any) { + if (!deviceInfo.type) { + deviceInfo.type = BrokerSecurityType.ANONYMOUS; + } + this.securityFormGroup.reset(deviceInfo); + this.updateView(deviceInfo); + } + + validate(): ValidationErrors | null { + return this.securityFormGroup.valid ? null : { + securityForm: { valid: false } + }; + } + + updateView(value: any) { + this.propagateChange(value); + } + + private updateValidators(type) { + if (type) { + this.securityFormGroup.get('username').disable({emitEvent: false}); + this.securityFormGroup.get('password').disable({emitEvent: false}); + this.securityFormGroup.get('pathToCACert').disable({emitEvent: false}); + this.securityFormGroup.get('pathToPrivateKey').disable({emitEvent: false}); + this.securityFormGroup.get('pathToClientCert').disable({emitEvent: false}); + if (type === BrokerSecurityType.BASIC) { + this.securityFormGroup.get('username').enable({emitEvent: false}); + this.securityFormGroup.get('password').enable({emitEvent: false}); + } else if (type === BrokerSecurityType.CERTIFICATES) { + this.securityFormGroup.get('pathToCACert').enable({emitEvent: false}); + this.securityFormGroup.get('pathToPrivateKey').enable({emitEvent: false}); + this.securityFormGroup.get('pathToClientCert').enable({emitEvent: false}); + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.html new file mode 100644 index 0000000000..2a3108b8b4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.html @@ -0,0 +1,97 @@ + +
+
device.device
+
+
+
gateway.device-info.entity-field
+
gateway.device-info.source
+
+ gateway.device-info.expression +
+
+
+
+
gateway.device-info.name
+
+ + + + {{ SourceTypeTranslationsMap.get(type) | translate }} + + + +
+
+ + + + warning + +
+
+
+
+
+
+
gateway.device-info.profile-name
+
+ + + + {{ SourceTypeTranslationsMap.get(type) | translate }} + + + +
+
+ + + + warning + +
+
+
+
+
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.scss new file mode 100644 index 0000000000..9dbaa52831 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.scss @@ -0,0 +1,57 @@ +/** + * 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. + */ +:host { + width: 100%; + height: 100%; + display: block; + + .tb-form-row { + &.bottom-same-padding { + padding-bottom: 16px; + } + + &.top-same-padding { + padding-top: 16px; + } + + .fixed-title-width { + width: 19%; + } + } + + .table-column { + width: 40%; + } + + .table-name-column { + width: 20%; + } + + .raw-name { + width: 19%; + } + + .raw-value-option { + max-width: 40%; + } + +} + +:host ::ng-deep { + .mat-mdc-form-field-icon-suffix { + display: flex; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.ts new file mode 100644 index 0000000000..8a40f25430 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/device-info-table.component.ts @@ -0,0 +1,179 @@ +/// +/// 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. +/// + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + Input, + NgZone, + OnDestroy, + OnInit, + ViewContainerRef +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { Overlay } from '@angular/cdk/overlay'; +import { UtilsService } from '@core/services/utils.service'; +import { EntityService } from '@core/http/entity.service'; +import { + ControlValueAccessor, + FormBuilder, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormGroup, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { + DeviceInfoType, + noLeadTrailSpacesRegex, + SourceTypes, + SourceTypeTranslationsMap +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +@Component({ + selector: 'tb-device-info-table', + templateUrl: './device-info-table.component.html', + styleUrls: ['./device-info-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceInfoTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceInfoTableComponent), + multi: true + } + ] +}) +export class DeviceInfoTableComponent extends PageComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { + + SourceTypeTranslationsMap = SourceTypeTranslationsMap; + + DeviceInfoType = DeviceInfoType; + + @coerceBoolean() + @Input() + useSource = true; + + @coerceBoolean() + @Input() + required = false; + + @Input() + sourceTypes: Array = Object.values(SourceTypes); + + deviceInfoTypeValue: any; + + get deviceInfoType(): any { + return this.deviceInfoTypeValue; + } + + @Input() + set deviceInfoType(value: any) { + if (this.deviceInfoTypeValue !== value) { + this.deviceInfoTypeValue = value; + } + } + + mappingFormGroup: UntypedFormGroup; + + private destroy$ = new Subject(); + private propagateChange = (v: any) => {}; + + constructor(protected store: Store, + public translate: TranslateService, + public dialog: MatDialog, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private dialogService: DialogService, + private entityService: EntityService, + private utils: UtilsService, + private zone: NgZone, + private cd: ChangeDetectorRef, + private elementRef: ElementRef, + private fb: FormBuilder) { + super(store); + } + + ngOnInit() { + this.mappingFormGroup = this.fb.group({ + deviceNameExpression: ['', this.required ? + [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)] : [Validators.pattern(noLeadTrailSpacesRegex)]] + }); + + if (this.useSource) { + this.mappingFormGroup.addControl('deviceNameExpressionSource', + this.fb.control(SourceTypes.MSG, [])); + } + + if (this.deviceInfoType === DeviceInfoType.FULL) { + if (this.useSource) { + this.mappingFormGroup.addControl('deviceProfileExpressionSource', + this.fb.control(SourceTypes.MSG, [])); + } + this.mappingFormGroup.addControl('deviceProfileExpression', + this.fb.control('', this.required ? + [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)] : [Validators.pattern(noLeadTrailSpacesRegex)])); + } + + this.mappingFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + this.updateView(value); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void {} + + writeValue(deviceInfo: any) { + this.mappingFormGroup.patchValue(deviceInfo, {emitEvent: false}); + } + + validate(): ValidationErrors | null { + return this.mappingFormGroup.valid ? null : { + mappingForm: { valid: false } + }; + } + + updateView(value: any) { + this.propagateChange(value); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/ellipsis-chip-list.directive.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/ellipsis-chip-list.directive.ts new file mode 100644 index 0000000000..aa4738d707 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/ellipsis-chip-list.directive.ts @@ -0,0 +1,128 @@ +/// +/// 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. +/// + +import { + Directive, + ElementRef, + Inject, + Input, + OnDestroy, + Renderer2 +} from '@angular/core'; +import { isEqual } from '@core/utils'; +import { TranslateService } from '@ngx-translate/core'; +import { WINDOW } from '@core/services/window.service'; +import { fromEvent, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[tb-ellipsis-chip-list]' +}) +export class EllipsisChipListDirective implements OnDestroy { + + chipsValue: string[]; + + private destroy$ = new Subject(); + + @Input('tb-ellipsis-chip-list') + set chips(value: string[]) { + if (!isEqual(this.chipsValue, value)) { + this.chipsValue = value; + setTimeout(() => { + this.adjustChips(); + }, 0); + } + } + + constructor(private el: ElementRef, + private renderer: Renderer2, + private translate: TranslateService, + @Inject(WINDOW) private window: Window) { + this.renderer.setStyle(this.el.nativeElement, 'max-height', '48px'); + this.renderer.setStyle(this.el.nativeElement, 'overflow', 'auto'); + fromEvent(window, 'resize').pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + this.adjustChips(); + }); + } + + private adjustChips(): void { + const chipListElement = this.el.nativeElement; + const ellipsisChip = this.el.nativeElement.querySelector('.ellipsis-chip'); + const margin = parseFloat(this.window.getComputedStyle(ellipsisChip).marginLeft) || 0; + const chipNodes = chipListElement.querySelectorAll('mat-chip:not(.ellipsis-chip)'); + + if (this.chipsValue.length > 1) { + const ellipsisText = this.el.nativeElement.querySelector('.ellipsis-text'); + this.renderer.setStyle(ellipsisChip, 'display', 'inline-flex'); + ellipsisText.innerHTML = this.translate.instant('gateway.ellipsis-chips-text', + {count: (this.chipsValue.length)}); + + const availableWidth = chipListElement.offsetWidth - (ellipsisChip.offsetWidth + margin); + let usedWidth = 0; + let visibleChipsCount = 0; + + chipNodes.forEach((chip) => { + this.renderer.setStyle(chip, 'display', 'inline-flex'); + const textLabelContainer = chip.querySelector('.mdc-evolution-chip__text-label'); + + this.applyMaxChipTextWidth(textLabelContainer, (availableWidth / 3)); + + if ((usedWidth + (chip.offsetWidth + margin) <= availableWidth) && (visibleChipsCount < this.chipsValue.length)) { + visibleChipsCount++; + usedWidth += chip.offsetWidth + margin; + } else { + this.renderer.setStyle(chip, 'display', 'none'); + } + }); + + ellipsisText.innerHTML = this.translate.instant('gateway.ellipsis-chips-text', + {count: (this.chipsValue.length - visibleChipsCount)}); + + if (visibleChipsCount === this.chipsValue?.length) { + this.renderer.setStyle(ellipsisChip, 'display', 'none'); + } + } else if (this.chipsValue.length === 1) { + const chipLabelContainer = chipNodes[0].querySelector('.mdc-evolution-chip__action'); + const textLabelContainer = chipLabelContainer.querySelector('.mdc-evolution-chip__text-label'); + const leftPadding = parseFloat(this.window.getComputedStyle(chipLabelContainer).paddingLeft) || 0; + const rightPadding = parseFloat(this.window.getComputedStyle(chipLabelContainer).paddingRight) || 0; + const computedTextWidth = chipListElement.offsetWidth - margin - + (leftPadding + rightPadding); + + this.renderer.setStyle(ellipsisChip, 'display', 'none'); + this.renderer.setStyle(chipNodes[0], 'display', 'inline-flex'); + + this.applyMaxChipTextWidth(textLabelContainer, computedTextWidth); + } else { + this.renderer.setStyle(ellipsisChip, 'display', 'none'); + } + } + + private applyMaxChipTextWidth(element: HTMLElement, widthLimit: number): void { + this.renderer.setStyle(element, 'max-width', widthLimit + 'px'); + this.renderer.setStyle(element, 'overflow', 'hidden'); + this.renderer.setStyle(element, 'text-overflow', 'ellipsis'); + this.renderer.setStyle(element, 'white-space', 'nowrap'); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.html new file mode 100644 index 0000000000..4c3a6434fd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.html @@ -0,0 +1,195 @@ + +
+
+
{{ panelTitle | translate }}{{' (' + keysListFormArray.controls.length + ')'}}
+
+
+
+ + + + +
{{ keyControl.get('key').value }}
+ {{ '-' }} +
{{ valueTitle(keyControl.get('value').value) }}
+
+
+ +
+
+
gateway.platform-side
+
+
+ gateway.key +
+
+ + + + warning + +
+
+
+
+
+
+
+
gateway.connector-side
+
+
gateway.type
+ + + +
+ + + + {{ (rawData ? 'gateway.raw' : valueTypes.get(keyControl.get('type').value)?.name) | translate}} + +
+
+ + + + + {{ valueTypes.get(valueType).name | translate }} + + + + + {{ 'gateway.raw' | translate }} + + +
+
+
+
+
+ gateway.value +
+ + + + warning + +
+
+
+
+
+
+ +
+
gateway.key
+
+ + + + warning + + +
+
+
+
gateway.value
+ + + + warning + + +
+
+
+
+
+
+ +
+
+
+ +
+
+ +
+ {{ noKeysText }} +
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.scss new file mode 100644 index 0000000000..24498863c9 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.scss @@ -0,0 +1,60 @@ +/** + * 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. + */ + +:host { + .tb-mapping-keys-panel { + width: 77vw; + max-width: 700px; + + .title-container { + max-width: 11vw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap + } + + .key-panel { + height: 500px; + overflow: auto; + } + + tb-value-input { + width: 100%; + } + + .tb-form-panel { + .mat-mdc-icon-button { + width: 56px; + height: 56px; + padding: 16px; + color: rgba(0, 0, 0, 0.54); + } + } + + .see-example { + width: 32px; + height: 32px; + margin: 4px; + } + } +} + +:host ::ng-deep { + .mat-mdc-form-field-icon-suffix { + display: flex; + } +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.ts new file mode 100644 index 0000000000..40ee2728bd --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component.ts @@ -0,0 +1,173 @@ +/// +/// 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. +/// + +import { + Component, + EventEmitter, + Input, + OnInit, + Output +} from '@angular/core'; +import { + AbstractControl, + UntypedFormArray, + UntypedFormBuilder, + Validators +} from '@angular/forms'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { coerceBoolean } from '@shared/decorators/coercion'; +import { TbPopoverComponent } from '@shared/components/popover.component'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { isDefinedAndNotNull } from '@core/utils'; +import { + MappingDataKey, + MappingKeysType, + MappingValueType, + mappingValueTypesMap, + noLeadTrailSpacesRegex +} from '@home/components/widget/lib/gateway/gateway-widget.models'; + +@Component({ + selector: 'tb-mapping-data-keys-panel', + templateUrl: './mapping-data-keys-panel.component.html', + styleUrls: ['./mapping-data-keys-panel.component.scss'], + providers: [] +}) +export class MappingDataKeysPanelComponent extends PageComponent implements OnInit { + + @Input() + panelTitle: string; + + @Input() + addKeyTitle: string; + + @Input() + deleteKeyTitle: string; + + @Input() + noKeysText: string; + + @Input() + keys: Array | {[key: string]: any}; + + @Input() + keysType: string; + + @Input() + @coerceBoolean() + rawData = false; + + @Input() + popover: TbPopoverComponent; + + @Output() + keysDataApplied = new EventEmitter | {[key: string]: any}>(); + + valueTypeKeys = Object.values(MappingValueType); + + MappingKeysType = MappingKeysType; + + valueTypeEnum = MappingValueType; + + valueTypes = mappingValueTypesMap; + + dataKeyType: DataKeyType; + + keysListFormArray: UntypedFormArray; + + errorText = ''; + + constructor(private fb: UntypedFormBuilder, + protected store: Store) { + super(store); + } + + ngOnInit() { + this.keysListFormArray = this.prepareKeysFormArray(this.keys) + } + + trackByKey(index: number, keyControl: AbstractControl): any { + return keyControl; + } + + addKey(): void { + const dataKeyFormGroup = this.fb.group({ + key: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + value: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]] + }); + if (this.keysType !== MappingKeysType.CUSTOM) { + dataKeyFormGroup.addControl('type', this.fb.control(this.rawData ? 'raw' : MappingValueType.STRING)); + } + this.keysListFormArray.push(dataKeyFormGroup); + } + + deleteKey($event: Event, index: number): void { + if ($event) { + $event.stopPropagation(); + } + this.keysListFormArray.removeAt(index); + this.keysListFormArray.markAsDirty(); + } + + cancel() { + this.popover?.hide(); + } + + applyKeysData() { + let keys = this.keysListFormArray.value; + if (this.keysType === MappingKeysType.CUSTOM) { + keys = {}; + for (let key of this.keysListFormArray.value) { + keys[key.key] = key.value; + } + } + this.keysDataApplied.emit(keys); + } + + private prepareKeysFormArray(keys: Array | {[key: string]: any}): UntypedFormArray { + const keysControlGroups: Array = []; + if (keys) { + if (this.keysType === MappingKeysType.CUSTOM) { + keys = Object.keys(keys).map(key => { + return {key, value: keys[key], type: ''}; + }); + } + keys.forEach((keyData) => { + const { key, value, type } = keyData; + const dataKeyFormGroup = this.fb.group({ + key: [key, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + value: [value, [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + type: [type, []] + }); + keysControlGroups.push(dataKeyFormGroup); + }); + } + return this.fb.array(keysControlGroups); + } + + valueTitle(value: any): string { + if (isDefinedAndNotNull(value)) { + if (typeof value === 'object') { + return JSON.stringify(value); + } + return value; + } + return ''; + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.html new file mode 100644 index 0000000000..e5c703d8d0 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.html @@ -0,0 +1,123 @@ + +
+
+ +
+
+ {{mappingTypeTranslationsMap.get(mappingType) | translate}} +
+ + + +
+
+ +
+ + +   + + + +
+
+
+
+ + + {{ column.title | translate }} + + + {{ mapping[column.def] }} + + + + + + +
+ + +
+
+ + + + + +
+
+
+ + +
+
+ +
+
+ + widget.no-data-found + +
+ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.scss new file mode 100644 index 0000000000..707b5fa056 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.scss @@ -0,0 +1,101 @@ +/** + * 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. + */ +@import '../scss/constants'; + +:host { + width: 100%; + height: 100%; + display: block; + .tb-mapping-table { + .tb-mapping-table-content { + width: 100%; + height: 100%; + background: #fff; + overflow: hidden; + + &.tb-outlined-border { + box-shadow: 0 0 0 0 rgb(0 0 0 / 20%), 0 0 0 0 rgb(0 0 0 / 14%), 0 0 0 0 rgb(0 0 0 / 12%); + border: solid 1px #e0e0e0; + border-radius: 4px; + } + + .mat-toolbar-tools{ + min-height: auto; + } + + .title-container{ + overflow: hidden; + } + + .tb-mapping-table-title { + padding-right: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .table-container { + overflow: auto; + .mat-mdc-table { + table-layout: fixed; + min-width: 450px; + + .table-value-column { + padding: 0 12px; + width: 23%; + + &.request-column { + width: 38%; + } + } + } + } + + .ellipsis { + overflow: hidden; + text-overflow: ellipsis; + } + } + } + + .no-data-found { + height: calc(100% - 120px); + } + + @media #{$mat-xs} { + .mat-toolbar { + height: auto; + min-height: 100px; + + .tb-mapping-table-title{ + padding-bottom: 5px; + width: 100%; + } + } + } +} + +:host ::ng-deep { + mat-cell.tb-value-cell { + cursor: pointer; + .mat-icon { + height: 24px; + width: 24px; + font-size: 24px; + color: #757575 + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.ts new file mode 100644 index 0000000000..4ef9695c65 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/connectors-configuration/mapping-table.component.ts @@ -0,0 +1,339 @@ +/// +/// 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. +/// + +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + forwardRef, + Input, + NgZone, + OnDestroy, + OnInit, + ViewChild, + ViewContainerRef +} from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { PageLink } from '@shared/models/page/page-link'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { TranslateService } from '@ngx-translate/core'; +import { MatDialog } from '@angular/material/dialog'; +import { DialogService } from '@core/services/dialog.service'; +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { debounceTime, distinctUntilChanged, map, takeUntil } from 'rxjs/operators'; +import { Overlay } from '@angular/cdk/overlay'; +import { UtilsService } from '@core/services/utils.service'; +import { EntityService } from '@core/http/entity.service'; +import { ControlValueAccessor, FormBuilder, NG_VALUE_ACCESSOR, UntypedFormArray } from '@angular/forms'; +import { + ConvertorTypeTranslationsMap, + MappingInfo, + MappingType, + MappingTypeTranslationsMap, + RequestType, + RequestTypesTranslationsMap +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { CollectionViewer, DataSource } from '@angular/cdk/collections'; +import { MappingDialogComponent } from '@home/components/widget/lib/gateway/dialog/mapping-dialog.component'; +import { isDefinedAndNotNull, isUndefinedOrNull } from '@core/utils'; + +@Component({ + selector: 'tb-mapping-table', + templateUrl: './mapping-table.component.html', + styleUrls: ['./mapping-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MappingTableComponent), + multi: true + } + ] +}) +export class MappingTableComponent extends PageComponent implements ControlValueAccessor, AfterViewInit, OnInit, OnDestroy { + + mappingTypeTranslationsMap = MappingTypeTranslationsMap; + mappingTypeEnum = MappingType; + displayedColumns = []; + mappingColumns = []; + textSearchMode = false; + dataSource: MappingDatasource; + hidePageSize = false; + + activeValue = false; + dirtyValue = false; + + viewsInited = false; + + mappingTypeValue: MappingType; + + get mappingType(): MappingType { + return this.mappingTypeValue; + } + + @Input() + set mappingType(value: MappingType) { + if (this.mappingTypeValue !== value) { + this.mappingTypeValue = value; + } + } + + @ViewChild('searchInput') searchInputField: ElementRef; + + mappingFormGroup: UntypedFormArray; + textSearch = this.fb.control('', {nonNullable: true}); + + private destroy$ = new Subject(); + private propagateChange = (v: any) => {}; + + constructor(protected store: Store, + public translate: TranslateService, + public dialog: MatDialog, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private dialogService: DialogService, + private entityService: EntityService, + private utils: UtilsService, + private zone: NgZone, + private cd: ChangeDetectorRef, + private elementRef: ElementRef, + private fb: FormBuilder) { + super(store); + this.mappingFormGroup = this.fb.array([]); + this.dirtyValue = !this.activeValue; + this.dataSource = new MappingDatasource(); + } + + ngOnInit() { + if (this.mappingType === MappingType.DATA) { + this.mappingColumns.push( + {def: 'topicFilter', title: 'gateway.topic-filter'}, + {def: 'QoS', title: 'gateway.mqtt-qos'}, + {def: 'converter', title: 'gateway.payload-type'} + ) + } else { + this.mappingColumns.push( + {def: 'type', title: 'gateway.type'}, + {def: 'details', title: 'gateway.details'} + ); + } + this.displayedColumns.push(...this.mappingColumns.map(column => column.def), 'actions'); + this.mappingFormGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + this.updateTableData(value); + this.updateView(value); + }); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } + + ngAfterViewInit() { + this.textSearch.valueChanges.pipe( + debounceTime(150), + distinctUntilChanged((prev, current) => (prev ?? '') === current.trim()), + takeUntil(this.destroy$) + ).subscribe((text) => { + const searchText = text.trim(); + this.updateTableData(this.mappingFormGroup.value, searchText.trim()) + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(fn: any): void {} + + writeValue(config: any) { + if (isUndefinedOrNull(config)) { + config = this.mappingType === MappingType.REQUESTS ? {} : []; + } + let mappingConfigs = config; + if (this.mappingType === MappingType.REQUESTS) { + mappingConfigs = []; + + Object.keys(config).forEach((configKey) => { + for (let mapping of config[configKey]) { + mappingConfigs.push({ + requestType: configKey, + requestValue: mapping + }); + } + }); + } + this.mappingFormGroup.clear({emitEvent: false}); + for (let mapping of mappingConfigs) { + this.mappingFormGroup.push(this.fb.group(mapping), {emitEvent: false}); + } + this.updateTableData(mappingConfigs); + } + + updateView(mappingConfigs: Array<{[key: string]: any}>) { + let config; + if (this.mappingType === MappingType.REQUESTS) { + config = {}; + for (let mappingConfig of mappingConfigs) { + if (config[mappingConfig.requestType]) { + config[mappingConfig.requestType].push(mappingConfig.requestValue); + } else { + config[mappingConfig.requestType] = [mappingConfig.requestValue]; + } + } + } else { + config = mappingConfigs; + } + + this.propagateChange(config); + } + + enterFilterMode() { + this.textSearchMode = true; + setTimeout(() => { + this.searchInputField.nativeElement.focus(); + this.searchInputField.nativeElement.setSelectionRange(0, 0); + }, 10); + } + + exitFilterMode() { + this.updateTableData(this.mappingFormGroup.value); + this.textSearchMode = false; + this.textSearch.reset(); + } + + manageMapping($event: Event, index?: number) { + if ($event) { + $event.stopPropagation(); + } + const value = isDefinedAndNotNull(index) ? this.mappingFormGroup.at(index).value : {}; + this.dialog.open(MappingDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + mappingType: this.mappingType, + value, + buttonTitle: isUndefinedOrNull(index) ? 'action.add' : 'action.apply' + } + }).afterClosed().subscribe( + (res) => { + if (res) { + if (isDefinedAndNotNull(index)) { + this.mappingFormGroup.at(index).patchValue(res); + } else { + this.mappingFormGroup.push(this.fb.group(res)); + } + } + } + ); + } + + updateTableData(value: Array<{[key: string]: any}>, textSearch?: string): void { + let tableValue = value; + if (this.mappingType === MappingType.DATA) { + tableValue = tableValue.map((value) => { + return { + topicFilter: value.topicFilter, + QoS: value.subscriptionQos, + converter: this.translate.instant(ConvertorTypeTranslationsMap.get(value.converter.type)) + }; + }); + } else { + tableValue = tableValue.map((value) => { + let details; + if (value.requestType === RequestType.ATTRIBUTE_UPDATE) { + details = value.requestValue.attributeFilter; + } else if (value.requestType === RequestType.SERVER_SIDE_RPC) { + details = value.requestValue.methodFilter; + } else { + details = value.requestValue.topicFilter; + } + return { + type: this.translate.instant(RequestTypesTranslationsMap.get(value.requestType)), + details + }; + }); + } + if (textSearch) { + tableValue = tableValue.filter(value => + Object.values(value).some(val => + val.toString().toLowerCase().includes(textSearch.toLowerCase()) + ) + ); + } + this.dataSource.loadMappings(tableValue); + } + + deleteMapping($event: Event, index: number) { + if ($event) { + $event.stopPropagation(); + } + this.dialogService.confirm( + this.translate.instant('gateway.delete-mapping-title'), + '', + this.translate.instant('action.no'), + this.translate.instant('action.yes'), + true + ).subscribe((result) => { + if (result) { + this.mappingFormGroup.removeAt(index); + } + }); + } + +} + +export class MappingDatasource implements DataSource<{[key: string]: any}> { + + private mappingSubject = new BehaviorSubject>([]); + + private allMappings: Observable>; + + constructor() {} + + connect(collectionViewer: CollectionViewer): Observable> { + return this.mappingSubject.asObservable(); + } + + disconnect(collectionViewer: CollectionViewer): void { + this.mappingSubject.complete(); + } + + loadMappings(mappings: Array<{[key: string]: any}>, pageLink?: PageLink, reload: boolean = false): void { + if (reload) { + this.allMappings = null; + } + this.mappingSubject.next(mappings); + } + + isEmpty(): Observable { + return this.mappingSubject.pipe( + map((mappings) => !mappings.length) + ); + } + + total(): Observable { + return this.mappingSubject.pipe( + map((mappings) => mappings.length) + ); + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.html new file mode 100644 index 0000000000..ec1fa9a87f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.html @@ -0,0 +1,100 @@ + +
+ +

{{ "gateway.add-connector" | translate}}

+ +
+ +
+
+
+
+
gateway.type
+
+ + + + {{ type.value }} + + + +
+
+
+
gateway.name
+
+ + + + warning + + +
+
+
+
gateway.remote-logging-level
+
+ + + {{ logLevel }} + + +
+
+
+ + + {{ 'gateway.fill-connector-defaults' | translate }} + + +
+
+ + + {{ 'gateway.send-change-data' | translate }} + + +
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.scss new file mode 100644 index 0000000000..56e4bc229c --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.scss @@ -0,0 +1,22 @@ +/** + * 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. + */ + +:host { + .add-connector { + min-width: 400px; + width: 500px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts new file mode 100644 index 0000000000..98261a8e57 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/add-connector-dialog.component.ts @@ -0,0 +1,121 @@ +/// +/// 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. +/// + +import { Component, Inject, OnDestroy } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, UntypedFormControl, UntypedFormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { BaseData, HasId } from '@shared/models/base-data'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { + AddConnectorConfigData, + ConnectorType, + CreatedConnectorConfigData, + GatewayConnectorDefaultTypesTranslatesMap, + GatewayLogLevel, + getDefaultConfig, + noLeadTrailSpacesRegex +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { Subject } from 'rxjs'; +import { ResourcesService } from '@core/services/resources.service'; + +@Component({ + selector: 'tb-add-connector-dialog', + templateUrl: './add-connector-dialog.component.html', + styleUrls: ['./add-connector-dialog.component.scss'], + providers: [], +}) +export class AddConnectorDialogComponent extends DialogComponent> implements OnDestroy { + + connectorForm: UntypedFormGroup; + + connectorType = ConnectorType; + + gatewayConnectorDefaultTypesTranslatesMap = GatewayConnectorDefaultTypesTranslatesMap; + gatewayLogLevel = Object.values(GatewayLogLevel); + + submitted = false; + + private destroy$ = new Subject(); + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: AddConnectorConfigData, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private resourcesService: ResourcesService) { + super(store, router, dialogRef); + this.connectorForm = this.fb.group({ + type: [ConnectorType.MQTT, []], + name: ['', [Validators.required, this.uniqNameRequired(), Validators.pattern(noLeadTrailSpacesRegex)]], + logLevel: [GatewayLogLevel.INFO, []], + useDefaults: [true, []], + sendDataOnlyOnChange: [false, []], + }); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + super.ngOnDestroy(); + } + + helpLinkId(): string { + return 'https://thingsboard.io/docs/iot-gateway/configuration/'; + } + + cancel(): void { + this.dialogRef.close(null); + } + + add(): void { + this.submitted = true; + const value = this.connectorForm.getRawValue(); + if (value.useDefaults) { + getDefaultConfig(this.resourcesService, value.type).subscribe((defaultConfig) => { + value.configurationJson = defaultConfig; + if (this.connectorForm.valid) { + this.dialogRef.close(value); + } + }); + } else if (this.connectorForm.valid) { + this.dialogRef.close(value); + } + } + + private uniqNameRequired(): ValidatorFn { + return (c: UntypedFormControl) => { + const newName = c.value.trim().toLowerCase(); + const found = this.data.dataSourceData.find((connectorAttr) => { + const connectorData = connectorAttr.value; + return connectorData.name.toLowerCase() === newName; + }); + if (found) { + if (c.hasError('required')) { + return c.getError('required'); + } + return { + duplicateName: { + valid: false + } + }; + } + return null; + }; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.html new file mode 100644 index 0000000000..78ceaa566f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.html @@ -0,0 +1,634 @@ + +
+ +

{{ MappingTypeTranslationsMap.get(this.data?.mappingType) | translate}}

+ +
+ +
+
+
+
+ {{ MappingHintTranslationsMap.get(this.data?.mappingType) | translate }} +
+ + +
+
gateway.topic-filter
+
+ + + + warning + +
+
+
+
+
+
+
+ gateway.mqtt-qos +
+
+ + + + {{ QualityTranslationsMap.get(type) | translate }} + + + +
+
+ +
+
gateway.payload-type
+ + + {{ ConvertorTypeTranslationsMap.get(type) | translate }} + + +
+
+
gateway.data-conversion
+
+ {{ DataConversionTranslationsMap.get(converterType) | translate }} +
+ + + + + + + + + +
+
+
gateway.attributes
+
+ + + {{ attribute }} + + + + + + +
+
+
+
gateway.timeseries
+
+ + + {{ telemetry }} + + + + + + +
+
+
+
+
+
+ gateway.extension +
+
+ + + + warning + + +
+
+
+
gateway.extension-configuration
+
{{ 'gateway.extension-configuration-hint' | translate }}
+
+
gateway.keys
+
+ + + {{ telemetry }} + + + + + + +
+
+
+
+
+
+
+
+ +
+
gateway.request-type
+
+ + + + {{ RequestTypesTranslationsMap.get(type) | translate }} + + + +
+
+ + +
+
gateway.topic-filter
+
+ + + + warning + +
+
+
+
+
+ + + + + + + + + +
+
gateway.from-device-request-settings
+
+ gateway.from-device-request-settings-hint +
+
+
+
gateway.device-info.device-name-expression
+
+
+ + + + {{ SourceTypeTranslationsMap.get(type) | translate }} + + + + + + + warning + +
+
+
+
+
+
+
gateway.attribute-name-expression
+
+ + + + {{ SourceTypeTranslationsMap.get(type) | translate }} + + + + + + + warning + +
+
+
+
+
+
+
+
gateway.to-device-response-settings
+
+ gateway.to-device-response-settings-hint +
+
+
gateway.response-value-expression
+
+ + + + warning + +
+
+
+
+
+
+
gateway.response-topic-expression
+
+ + + + warning + +
+
+
+
+
+
+ + + {{ 'gateway.retain' | translate }} + + +
+
+
+ +
+
+ gateway.device-name-filter +
+
+ + + + warning + + +
+
+
+
+ gateway.attribute-filter +
+
+ + + + warning + + +
+
+
+
gateway.response-value-expression
+
+ + + + warning + +
+
+
+
+
+
+
gateway.response-topic-expression
+
+ + + + warning + +
+
+
+
+
+
+ + + {{ 'gateway.retain' | translate }} + + +
+
+ +
+ + + {{ 'gateway.with-response' | translate }} + + + {{ 'gateway.without-response' | translate }} + + +
+
+
+ gateway.device-name-filter +
+
+ + + + warning + + +
+
+
+
+ gateway.method-filter +
+
+ + + + warning + + +
+
+
+
gateway.request-topic-expression
+
+ + + + warning + +
+
+
+
+
+
+
gateway.value-expression
+
+ + + + warning + +
+
+
+
+
+ +
+
gateway.response-topic-expression
+
+ + + + warning + +
+
+
+
+
+
+
+ gateway.response-topic-Qos +
+ + + + {{ QualityTranslationsMap.get(type) | translate }} + + + +
+
+
gateway.response-timeout
+
+ + + + warning + + +
+
+
+
+
+
+
+
+
+
+
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.scss new file mode 100644 index 0000000000..db98bc07cb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.scss @@ -0,0 +1,76 @@ +/** + * 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. + */ + +:host { + display: grid; + height: 100%; + + .key-mapping { + max-width: 900px; + display: flex; + flex-direction: column; + + .mat-toolbar { + min-height: 64px; + } + + tb-toggle-select { + padding: 4px 0; + } + } + + .mat-mdc-dialog-content { + max-height: 670px; + height: 670px; + } + + .ellipsis-chips-container { + max-width: 70%; + } +} + +:host ::ng-deep { + .key-mapping { + .mat-mdc-chip-listbox { + .mdc-evolution-chip-set__chips { + justify-content: flex-end; + align-items: center; + } + } + } + .tb-form-row { + .fixed-title-width { + min-width: 40px; + width: 35%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .mat-mdc-form-field { + width: 0; + } + } + + .see-example { + width: 32px; + height: 32px; + margin: 4px; + } + + .mat-mdc-form-field-icon-suffix { + display: flex; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.ts new file mode 100644 index 0000000000..d01050b52e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/dialog/mapping-dialog.component.ts @@ -0,0 +1,360 @@ +/// +/// 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. +/// + +import { Component, Inject, OnDestroy, Renderer2, ViewContainerRef } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { FormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; +import { BaseData, HasId } from '@shared/models/base-data'; +import { DialogComponent } from '@shared/components/dialog.component'; +import { Router } from '@angular/router'; +import { + ConvertorType, + ConvertorTypeTranslationsMap, + DataConversionTranslationsMap, + DeviceInfoType, + MappingHintTranslationsMap, + MappingInfo, + MappingKeysAddKeyTranslationsMap, + MappingKeysDeleteKeyTranslationsMap, + MappingKeysNoKeysTextTranslationsMap, + MappingKeysPanelTitleTranslationsMap, + MappingKeysType, + MappingType, + MappingTypeTranslationsMap, + noLeadTrailSpacesRegex, + QualityTypes, + QualityTypeTranslationsMap, + RequestType, + RequestTypesTranslationsMap, + ServerSideRPCType, + SourceTypes, + SourceTypeTranslationsMap +} from '@home/components/widget/lib/gateway/gateway-widget.models'; +import { Subject } from 'rxjs'; +import { startWith, takeUntil } from 'rxjs/operators'; +import { MatButton } from '@angular/material/button'; +import { TbPopoverService } from '@shared/components/popover.service'; +import { MappingDataKeysPanelComponent } from '@home/components/widget/lib/gateway/connectors-configuration/mapping-data-keys-panel.component'; +import { TranslateService } from '@ngx-translate/core'; + +@Component({ + selector: 'tb-mapping-dialog', + templateUrl: './mapping-dialog.component.html', + styleUrls: ['./mapping-dialog.component.scss'], + providers: [], +}) +export class MappingDialogComponent extends DialogComponent> implements OnDestroy { + + mappingForm: UntypedFormGroup; + + MappingType = MappingType; + + qualityTypes = QualityTypes; + QualityTranslationsMap = QualityTypeTranslationsMap; + + convertorTypes = Object.values(ConvertorType); + ConvertorTypeEnum = ConvertorType; + ConvertorTypeTranslationsMap = ConvertorTypeTranslationsMap; + + sourceTypes = Object.values(SourceTypes); + sourceTypesEnum = SourceTypes; + SourceTypeTranslationsMap = SourceTypeTranslationsMap; + + requestTypes = Object.values(RequestType); + RequestTypeEnum = RequestType; + RequestTypesTranslationsMap = RequestTypesTranslationsMap; + + DeviceInfoType = DeviceInfoType; + + ServerSideRPCType = ServerSideRPCType; + + MappingKeysType = MappingKeysType; + + MappingHintTranslationsMap = MappingHintTranslationsMap; + + MappingTypeTranslationsMap = MappingTypeTranslationsMap; + + DataConversionTranslationsMap = DataConversionTranslationsMap; + + hiddenAttributesCount = 0; + + keysPopupClosed = true; + + submitted = false; + + private destroy$ = new Subject(); + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: MappingInfo, + public dialogRef: MatDialogRef, + private fb: FormBuilder, + private popoverService: TbPopoverService, + private renderer: Renderer2, + private viewContainerRef: ViewContainerRef, + private translate: TranslateService) { + super(store, router, dialogRef); + + this.createMappingForm(); + } + + get converterAttributes(): Array { + if (this.converterType) { + return this.mappingForm.get('converter').get(this.converterType).value.attributes.map(value => value.key); + } + } + + get converterTelemetry(): Array { + if (this.converterType) { + return this.mappingForm.get('converter').get(this.converterType).value.timeseries.map(value => value.key); + } + } + + get converterType(): ConvertorType { + return this.mappingForm.get('converter').get('type').value; + } + + get customKeys(): Array { + return Object.keys(this.mappingForm.get('converter').get('custom').value.extensionConfig); + } + + get requestMappingType(): RequestType { + return this.mappingForm.get('requestType').value; + } + + get responseTimeoutErrorTooltip(): string { + const control = this.mappingForm.get('requestValue.serverSideRpc.responseTimeout'); + if (control.hasError('required')) { + return this.translate.instant('gateway.response-timeout-required'); + } else if (control.hasError('min')) { + return this.translate.instant('gateway.response-timeout-limits-error', {min: 1}); + } + return ''; + } + + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + super.ngOnDestroy(); + } + + private createMappingForm(): void { + this.mappingForm = this.fb.group({}); + if (this.data.mappingType === MappingType.DATA) { + this.mappingForm.addControl('topicFilter', + this.fb.control('', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)])); + this.mappingForm.addControl('subscriptionQos', this.fb.control(0)); + this.mappingForm.addControl('converter', this.fb.group({ + type: [ConvertorType.JSON, []], + json: this.fb.group({ + deviceInfo: [{}, []], + attributes: [[], []], + timeseries: [[], []] + }), + bytes: this.fb.group({ + deviceInfo: [{}, []], + attributes: [[], []], + timeseries: [[], []] + }), + custom: this.fb.group({ + extension: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + extensionConfig: [{}, []] + }), + })); + this.mappingForm.patchValue(this.prepareFormValueData()); + this.mappingForm.get('converter.type').valueChanges.pipe( + startWith(this.mappingForm.get('converter.type').value), + takeUntil(this.destroy$) + ).subscribe((value) => { + const converterGroup = this.mappingForm.get('converter'); + converterGroup.get('json').disable({emitEvent: false}); + converterGroup.get('bytes').disable({emitEvent: false}); + converterGroup.get('custom').disable({emitEvent: false}); + converterGroup.get(value).enable({emitEvent: false}); + }) + } + + if (this.data.mappingType === MappingType.REQUESTS) { + this.mappingForm.addControl('requestType', this.fb.control(RequestType.CONNECT_REQUEST, [])); + this.mappingForm.addControl('requestValue', this.fb.group({ + connectRequests: this.fb.group({ + topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + deviceInfo: [{}, []] + }), + disconnectRequests: this.fb.group({ + topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + deviceInfo: [{}, []] + }), + attributeRequests: this.fb.group({ + topicFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + deviceInfo: this.fb.group({ + deviceNameExpressionSource: [SourceTypes.MSG, []], + deviceNameExpression: ['', [Validators.required]], + }), + attributeNameExpressionSource: [SourceTypes.MSG, []], + attributeNameExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + topicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + retain: [false, []] + }), + attributeUpdates: this.fb.group({ + deviceNameFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + attributeFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + topicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + retain: [true, []] + }), + serverSideRpc: this.fb.group({ + type: [ServerSideRPCType.TWO_WAY, []], + deviceNameFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + methodFilter: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + requestTopicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + responseTopicExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + valueExpression: ['', [Validators.required, Validators.pattern(noLeadTrailSpacesRegex)]], + responseTopicQoS: [0, []], + responseTimeout: [10000, [Validators.required, Validators.min(1)]], + }) + })); + this.mappingForm.get('requestType').valueChanges.pipe( + startWith(this.mappingForm.get('requestType').value), + takeUntil(this.destroy$) + ).subscribe((value) => { + const requestValueGroup = this.mappingForm.get('requestValue'); + requestValueGroup.get('connectRequests').disable({emitEvent: false}); + requestValueGroup.get('disconnectRequests').disable({emitEvent: false}); + requestValueGroup.get('attributeRequests').disable({emitEvent: false}); + requestValueGroup.get('attributeUpdates').disable({emitEvent: false}); + requestValueGroup.get('serverSideRpc').disable({emitEvent: false}); + requestValueGroup.get(value).enable(); + }); + this.mappingForm.get('requestValue.serverSideRpc.type').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe((value) => { + const requestValueGroup = this.mappingForm.get('requestValue.serverSideRpc'); + if (value === ServerSideRPCType.ONE_WAY) { + requestValueGroup.get('responseTopicExpression').disable({emitEvent: false}); + requestValueGroup.get('responseTopicQoS').disable({emitEvent: false}); + requestValueGroup.get('responseTimeout').disable({emitEvent: false}); + } else { + requestValueGroup.get('responseTopicExpression').enable({emitEvent: false}); + requestValueGroup.get('responseTopicQoS').enable({emitEvent: false}); + requestValueGroup.get('responseTimeout').enable({emitEvent: false}); + } + }); + this.mappingForm.patchValue(this.prepareFormValueData()); + } + } + + helpLinkId(): string { + return 'https://thingsboard.io/docs/iot-gateway/config/mqtt/#section-mapping'; + } + + cancel(): void { + if (this.keysPopupClosed) { + this.dialogRef.close(null); + } + } + + add(): void { + this.submitted = true; + if (this.mappingForm.valid) { + this.dialogRef.close(this.prepareMappingData()); + } + } + + manageKeys($event: Event, matButton: MatButton, keysType: MappingKeysType) { + if ($event) { + $event.stopPropagation(); + } + const trigger = matButton._elementRef.nativeElement; + if (this.popoverService.hasPopover(trigger)) { + this.popoverService.hidePopover(trigger); + } else { + const keysControl = this.mappingForm.get('converter').get(this.converterType).get(keysType); + const ctx: any = { + keys: keysControl.value, + keysType: keysType, + rawData: this.mappingForm.get('converter.type').value === ConvertorType.BYTES, + panelTitle: MappingKeysPanelTitleTranslationsMap.get(keysType), + addKeyTitle: MappingKeysAddKeyTranslationsMap.get(keysType), + deleteKeyTitle: MappingKeysDeleteKeyTranslationsMap.get(keysType), + noKeysText: MappingKeysNoKeysTextTranslationsMap.get(keysType) + }; + this.keysPopupClosed = false; + const dataKeysPanelPopover = this.popoverService.displayPopover(trigger, this.renderer, + this.viewContainerRef, MappingDataKeysPanelComponent, 'leftBottom', false, null, + ctx, + {}, + {}, {}, true); + dataKeysPanelPopover.tbComponentRef.instance.popover = dataKeysPanelPopover; + dataKeysPanelPopover.tbComponentRef.instance.keysDataApplied.subscribe((keysData) => { + dataKeysPanelPopover.hide(); + keysControl.patchValue(keysData); + keysControl.markAsDirty(); + }); + dataKeysPanelPopover.tbHideStart.subscribe(() => { + this.keysPopupClosed = true; + }); + } + } + + private prepareMappingData(): {[key: string]: any} { + const formValue = this.mappingForm.value; + if (this.data.mappingType === MappingType.DATA) { + const { converter, topicFilter, subscriptionQos } = formValue; + return { + topicFilter, + subscriptionQos, + converter: { + type: converter.type, + ...converter[converter.type] + } + }; + } else { + return { + requestType: formValue.requestType, + requestValue: formValue.requestValue[formValue.requestType] + }; + } + } + + private prepareFormValueData(): {[key: string]: any} { + if (this.data.value && Object.keys(this.data.value).length) { + if (this.data.mappingType === MappingType.DATA) { + const { converter, topicFilter, subscriptionQos } = this.data.value; + return { + topicFilter, + subscriptionQos, + converter: { + type: converter.type, + [converter.type]: { ...converter } + } + }; + } else { + return { + requestType: this.data.value.requestType, + requestValue: { + [this.data.value.requestType]: this.data.value.requestValue + } + }; + } + } + return this.data.value; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html index 153b6b0e14..8aeaeef2ed 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.html @@ -80,6 +80,15 @@ {{ 'security.access-token-required' | translate }} + + info_outlined @@ -92,6 +101,15 @@ {{ 'security.clientId-required' | translate }} + + info_outlined @@ -102,6 +120,15 @@ {{ 'security.username-required' | translate }} + + info_outlined @@ -109,8 +136,17 @@ - security.password + gateway.password + + info_outlined diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.scss index 46292aa36e..f7562c748e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-configuration.component.scss @@ -97,5 +97,9 @@ color: #9E9E9E; } } + + .mat-mdc-form-field-icon-suffix { + display: flex; + } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html index 2b9329dc21..3a7f5403b9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/gateway/gateway-connectors.component.html @@ -22,13 +22,21 @@
+
+ +
@@ -74,6 +82,7 @@ @@ -119,7 +128,7 @@ private_connectivity
-
-
-
- - {{ 'gateway.connectors-table-name' | translate }} - - - - {{ 'gateway.connectors-table-type' | translate }} - - {{ type.value }} - - - - {{ 'gateway.connectors-table-key' | translate }} - - - - {{ 'gateway.connectors-table-class' | translate }} - - - - gateway.remote-logging-level - - {{ logLevel }} - - -
-
-
- - +
+
+
+ {{ initialConnector?.type ? gatewayConnectorDefaultTypes.get(initialConnector.type) : '' }} + {{ 'gateway.configuration' | translate }} +
+ + + {{ 'gateway.basic' | translate }} + + + {{ 'gateway.advanced' | translate }} + + +
+ + gateway.select-connector + +
+ + +
+
+
gateway.name
+
+ + + + warning + + +
+
+
+
gateway.logs-configuration
+
+ + + {{ 'gateway.enable-remote-logging' | translate }} + + +
+
+
gateway.remote-logging-level
+
+ + + {{ logLevel }} + + +
+
+
+
+ + + {{ 'gateway.send-change-data' | translate }} + + +
+
+
+ + + +
+
+
gateway.host
+
+ + + + warning + + +
+
+
+
gateway.port
+
+ + + + warning + + +
+
+
+
gateway.mqtt-version
+
+ + + {{ version.name }} + + +
+
+
+
gateway.client-id
+
+ + + + +
+
+ + +
+
+
+ +
+ +
+
+ +
+ +
+
+ +
+ +
+
+
+ gateway.max-number-of-workers +
+
+ + + + warning + + +
+
+
+
+ gateway.max-messages-queue-for-worker +
+
+ + + + warning + + +
+
+
+
+
+
+
+ + + + + + +
+
+ + diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss new file mode 100644 index 0000000000..40bffb8ca6 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.scss @@ -0,0 +1,24 @@ +/** + * 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. + */ + +.tb-qrcode-preview { + background-color: rgba(0, 0, 0, 0.04); + box-shadow: none; + + .tb-form-panel-title { + color: rgba(0, 0, 0, 0.38); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts new file mode 100644 index 0000000000..0423ce989f --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/admin/mobile-app-settings.component.ts @@ -0,0 +1,193 @@ +/// +/// 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. +/// + +import { Component, OnDestroy } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { PageComponent } from '@shared/components/page.component'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; +import { HasConfirmForm } from '@core/guards/confirm-on-exit.guard'; +import { Subject, takeUntil } from 'rxjs'; +import { MobileAppService } from '@core/http/mobile-app.service'; +import { + BadgePosition, + badgePositionTranslationsMap, + BadgeStyle, + badgeStyleTranslationsMap, + MobileAppSettings +} from '@shared/models/mobile-app.models'; + +@Component({ + selector: 'tb-mobile-app-settings', + templateUrl: './mobile-app-settings.component.html', + styleUrls: ['mobile-app-settings.component.scss', './settings-card.scss'] +}) +export class MobileAppSettingsComponent extends PageComponent implements HasConfirmForm, OnDestroy { + + mobileAppSettingsForm: FormGroup; + + mobileAppSettings: MobileAppSettings; + + private readonly destroy$ = new Subject(); + + badgePositionTranslationsMap = badgePositionTranslationsMap; + badgeStyleTranslationsMap = badgeStyleTranslationsMap; + + constructor(protected store: Store, + private mobileAppService: MobileAppService, + private fb: FormBuilder) { + super(store); + this.buildMobileAppSettingsForm(); + this.mobileAppService.getMobileAppSettings() + .subscribe(settings => this.processMobileAppSettings(settings)); + this.mobileAppSettingsForm.get('useDefaultApp').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + this.mobileAppSettingsForm.get('androidConfig.appPackage').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('iosConfig.appId').disable({emitEvent: false}); + } else { + if (this.mobileAppSettingsForm.get('androidConfig.enabled').value) { + this.mobileAppSettingsForm.get('androidConfig.appPackage').enable({emitEvent: false}); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').enable({emitEvent: false}); + } + if (this.mobileAppSettingsForm.get('iosConfig.enabled').value) { + this.mobileAppSettingsForm.get('iosConfig.appId').enable({emitEvent: false}); + } + } + }); + this.mobileAppSettingsForm.get('androidConfig.enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + this.androidEnableChanged(value); + }); + this.mobileAppSettingsForm.get('iosConfig.enabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + this.iosEnableChanged(value); + }); + this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + if (this.mobileAppSettingsForm.get('androidConfig.enabled').value || this.mobileAppSettingsForm.get('iosConfig.enabled').value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable({emitEvent: false}); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable({emitEvent: false}); + } + } else { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable({emitEvent: false}); + } + }); + this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabelEnabled').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(value => { + if (value) { + this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').enable({emitEvent: false}); + } else { + this.mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').disable({emitEvent: false}); + } + }); + } + + ngOnDestroy() { + super.ngOnDestroy(); + this.destroy$.next(); + this.destroy$.complete(); + } + + private buildMobileAppSettingsForm() { + this.mobileAppSettingsForm = this.fb.group({ + useDefaultApp: [true], + androidConfig: this.fb.group({ + enabled: [true], + appPackage: [{value: '', disabled: true}, [Validators.required]], + sha256CertFingerprints: [{value: '', disabled: true}, [Validators.required]] + }), + iosConfig: this.fb.group({ + enabled: [true], + appId: [{value: '', disabled: true}, [Validators.required]] + }), + qrCodeConfig: this.fb.group({ + showOnHomePage: [true], + badgeEnabled: [true], + badgeStyle: [{value: BadgeStyle.ORIGINAL, disabled: true}], + badgePosition: [{value: BadgePosition.RIGHT, disabled: true}], + qrCodeLabelEnabled: [true], + qrCodeLabel: ['', [Validators.required]] + }) + }); + } + + private processMobileAppSettings(mobileAppSettings: MobileAppSettings): void { + this.mobileAppSettings = {...mobileAppSettings}; + this.mobileAppSettingsForm.reset(this.mobileAppSettings); + } + + private androidEnableChanged(value: boolean): void { + if (value) { + if (!this.mobileAppSettingsForm.get('useDefaultApp').value) { + this.mobileAppSettingsForm.get('androidConfig.appPackage').enable({emitEvent: false}); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').enable({emitEvent: false}); + } + } else { + this.mobileAppSettingsForm.get('androidConfig.appPackage').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('androidConfig.sha256CertFingerprints').disable({emitEvent: false}); + } + this.updateBadgeControls(value); + } + + private iosEnableChanged(value: boolean): void { + if (value) { + if (!this.mobileAppSettingsForm.get('useDefaultApp').value) { + this.mobileAppSettingsForm.get('iosConfig.appId').enable({emitEvent: false}); + } + } else { + this.mobileAppSettingsForm.get('iosConfig.appId').disable({emitEvent: false}); + } + this.updateBadgeControls(value); + } + + private updateBadgeControls(value: boolean) { + if (this.mobileAppSettingsForm.get('qrCodeConfig.showOnHomePage').value) { + if (value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').enable({emitEvent: false}); + if (this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').enable({emitEvent: false}); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').enable({emitEvent: false}); + } + } else { + if (!this.mobileAppSettingsForm.get('iosConfig.enabled').value && !this.mobileAppSettingsForm.get('androidConfig.enabled').value) { + this.mobileAppSettingsForm.get('qrCodeConfig.badgeEnabled').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('qrCodeConfig.badgeStyle').disable({emitEvent: false}); + this.mobileAppSettingsForm.get('qrCodeConfig.badgePosition').disable({emitEvent: false}); + } + } + } + } + + save(): void { + this.mobileAppSettings = {...this.mobileAppSettings, ...this.mobileAppSettingsForm.getRawValue()}; + this.mobileAppService.saveMobileAppSettings(this.mobileAppSettings) + .subscribe((settings) => this.processMobileAppSettings(settings)); + } + + confirmForm(): FormGroup { + return this.mobileAppSettingsForm; + } + +} diff --git a/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts b/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts index 6c5789b218..a455bac64d 100644 --- a/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/home-links/home-links-routing.module.ts @@ -25,7 +25,12 @@ import { DashboardService } from '@core/http/dashboard.service'; import { select, Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { map } from 'rxjs/operators'; -import { getCurrentAuthUser, selectPersistDeviceStateToTelemetry } from '@core/auth/auth.selectors'; +import { + getCurrentAuthUser, + selectHomeDashboardParams, + selectMobileQrEnabled, + selectPersistDeviceStateToTelemetry +} from '@core/auth/auth.selectors'; import { EntityKeyType } from '@shared/models/query/query.models'; import { ResourcesService } from '@core/services/resources.service'; @@ -33,22 +38,67 @@ const sysAdminHomePageJson = '/assets/dashboard/sys_admin_home_page.json'; const tenantAdminHomePageJson = '/assets/dashboard/tenant_admin_home_page.json'; const customerUserHomePageJson = '/assets/dashboard/customer_user_home_page.json'; -const updateDeviceActivityKeyFilterIfNeeded = (store: Store, - dashboard$: Observable): Observable => - store.pipe(select(selectPersistDeviceStateToTelemetry)).pipe( - mergeMap((persistToTelemetry) => dashboard$.pipe( +const getHomeDashboard = (store: Store, resourcesService: ResourcesService) => { + const authority = getCurrentAuthUser(store).authority; + switch (authority) { + case Authority.SYS_ADMIN: + return applySystemParametersToHomeDashboard(store, resourcesService.loadJsonResource(sysAdminHomePageJson), authority); + case Authority.TENANT_ADMIN: + return applySystemParametersToHomeDashboard(store, resourcesService.loadJsonResource(tenantAdminHomePageJson), authority); + case Authority.CUSTOMER_USER: + return applySystemParametersToHomeDashboard(store, resourcesService.loadJsonResource(customerUserHomePageJson), authority); + default: + return of(null); + } +}; + +const applySystemParametersToHomeDashboard = (store: Store, + dashboard$: Observable, + authority: Authority): Observable => { + let selectParams$: Observable<{persistDeviceStateToTelemetry?: boolean, mobileQrEnabled?: boolean}>; + switch (authority) { + case Authority.SYS_ADMIN: + selectParams$ = store.pipe( + select(selectMobileQrEnabled), + map(mobileQrEnabled => ({mobileQrEnabled})) + ); + break; + case Authority.TENANT_ADMIN: + selectParams$ = store.pipe(select(selectHomeDashboardParams)); + break; + case Authority.CUSTOMER_USER: + selectParams$ = store.pipe( + select(selectPersistDeviceStateToTelemetry), + map(persistDeviceStateToTelemetry => ({persistDeviceStateToTelemetry})) + ); + break; + } + return selectParams$.pipe( + mergeMap((params) => dashboard$.pipe( map((dashboard) => { - if (persistToTelemetry) { + if (params.persistDeviceStateToTelemetry) { for (const filterId of Object.keys(dashboard.configuration.filters)) { if (['Active Devices', 'Inactive Devices'].includes(dashboard.configuration.filters[filterId].filter)) { dashboard.configuration.filters[filterId].keyFilters[0].key.type = EntityKeyType.TIME_SERIES; } } } + if (params.mobileQrEnabled) { + for (const widgetId of Object.keys(dashboard.configuration.widgets)) { + if (dashboard.configuration.widgets[widgetId].config.title === 'Select show mobile QR code') { + dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction = + (dashboard.configuration.widgets[widgetId].config.settings.markdownTextFunction as string) + .replace('\'${mobileQrEnabled}\'', String(true)); + break; + } + } + } + dashboard.hideDashboardToolbar = true; return dashboard; }) )) ); +}; export const homeDashboardResolver: ResolveFn = ( route: ActivatedRouteSnapshot, @@ -60,27 +110,7 @@ export const homeDashboardResolver: ResolveFn = ( dashboardService.getHomeDashboard().pipe( mergeMap((dashboard) => { if (!dashboard) { - let dashboard$: Observable; - const authority = getCurrentAuthUser(store).authority; - switch (authority) { - case Authority.SYS_ADMIN: - dashboard$ = resourcesService.loadJsonResource(sysAdminHomePageJson); - break; - case Authority.TENANT_ADMIN: - dashboard$ = updateDeviceActivityKeyFilterIfNeeded(store, resourcesService.loadJsonResource(tenantAdminHomePageJson)); - break; - case Authority.CUSTOMER_USER: - dashboard$ = updateDeviceActivityKeyFilterIfNeeded(store, resourcesService.loadJsonResource(customerUserHomePageJson)); - break; - } - if (dashboard$) { - return dashboard$.pipe( - map((homeDashboard) => { - homeDashboard.hideDashboardToolbar = true; - return homeDashboard; - }) - ); - } + return getHomeDashboard(store, resourcesService); } return of(dashboard); }) diff --git a/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts b/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts index fcd91a2397..9d65cf02a6 100644 --- a/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts +++ b/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts @@ -59,14 +59,14 @@ export class HexInputComponent { } public onInputChange(event: KeyboardEvent, inputValue: string): void { - const value = inputValue.toLowerCase(); + const value = inputValue.replace('#', '').toLowerCase(); if ( ((event.keyCode === 13 || event.key.toLowerCase() === 'enter') && value.length === 3) || value.length === 6 || value.length === 8 ) { const hex = parseInt(value, 16); const hexStr = hex.toString(16); - if (hexStr.padStart(value.length, '0') === value && this.value !== value) { + if (hexStr.padStart(value.length, '0') === value && this.value.toLowerCase() !== value) { const newColor = new Color(`#${value}`); this.colorChange.emit(newColor); } diff --git a/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.scss b/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.scss index 000aef0440..7ed9925300 100644 --- a/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.scss +++ b/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.scss @@ -17,13 +17,15 @@ display: flex; flex-direction: row; align-items: center; - gap: 8px; + gap: 4px; } :host { .tb-hint-tooltip-icon { color: #E0E0E0; overflow: visible; + order: 3; + margin-left: 4px; &:hover { color: #9E9E9E; } diff --git a/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts b/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts index 4794901f98..025a2ddf4c 100644 --- a/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts +++ b/ui-ngx/src/app/shared/import-export/export-widgets-bundle-dialog.component.ts @@ -22,6 +22,7 @@ import { FormControl } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; +import { isDefinedAndNotNull } from '@core/utils'; export interface ExportWidgetsBundleDialogData { widgetsBundle: WidgetsBundle; @@ -43,7 +44,7 @@ export class ExportWidgetsBundleDialogComponent extends DialogComponent, protected router: Router, @@ -51,7 +52,7 @@ export class ExportWidgetsBundleDialogComponent extends DialogComponent) { super(store, router, dialogRef); this.widgetsBundle = data.widgetsBundle; - if (data.includeBundleWidgetsInExport) { + if (isDefinedAndNotNull(data.includeBundleWidgetsInExport)) { this.exportWidgetsFormControl.patchValue(data.includeBundleWidgetsInExport, {emitEvent: false}); } } diff --git a/ui-ngx/src/app/shared/models/mobile-app.models.ts b/ui-ngx/src/app/shared/models/mobile-app.models.ts new file mode 100644 index 0000000000..262cc34f9e --- /dev/null +++ b/ui-ngx/src/app/shared/models/mobile-app.models.ts @@ -0,0 +1,80 @@ +/// +/// 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. +/// + +import { HasTenantId } from '@shared/models/entity.models'; + +export interface MobileAppSettings extends HasTenantId { + useDefaultApp: boolean; + androidConfig: AndroidConfig; + iosConfig: IosConfig; + qrCodeConfig: QRCodeConfig; +} + +export interface AndroidConfig { + enabled: boolean; + appPackage: string; + sha256CertFingerprints: string +} + +export interface IosConfig { + enabled: boolean; + appId: string; +} + +export interface QRCodeConfig { + showOnHomePage: boolean; + badgeEnabled: boolean; + badgePosition: BadgePosition; + badgeStyle: BadgeStyle; + qrCodeLabelEnabled: boolean; + qrCodeLabel: string; +} + +export interface MobileOSBadgeURL { + iOS: string; + android: string; +} + +export enum BadgePosition { + RIGHT = 'RIGHT', + LEFT = 'LEFT' +} + +export const badgePositionTranslationsMap = new Map([ + [BadgePosition.RIGHT, 'admin.mobile-app.right'], + [BadgePosition.LEFT, 'admin.mobile-app.left'] +]); + +export enum BadgeStyle { + ORIGINAL = 'ORIGINAL', + WHITE = 'WHITE' +} + +export const badgeStyleTranslationsMap = new Map([ + [BadgeStyle.ORIGINAL, 'admin.mobile-app.original'], + [BadgeStyle.WHITE, 'admin.mobile-app.white'] +]); + +export const badgeStyleURLMap = new Map([ + [BadgeStyle.ORIGINAL, { + iOS: 'assets/android-ios-stores-badges/ios_store_en_black_badge.svg', + android: 'assets/android-ios-stores-badges/android_store_en_black_badge.svg' + }], + [BadgeStyle.WHITE, { + iOS: 'assets/android-ios-stores-badges/ios_store_en_white_badge.svg', + android: 'assets/android-ios-stores-badges/android_store_en_black_badge.svg' + }] +]); diff --git a/ui-ngx/src/app/shared/models/time/time.models.ts b/ui-ngx/src/app/shared/models/time/time.models.ts index deec2ee8f9..9336f47d90 100644 --- a/ui-ngx/src/app/shared/models/time/time.models.ts +++ b/ui-ngx/src/app/shared/models/time/time.models.ts @@ -1116,8 +1116,8 @@ export const endIntervalDate = (current: moment_.Moment, interval: IntervalType) }; export const calculateAggIntervalWithSubscriptionTimeWindow - = (subsTw: SubscriptionTimewindow, endTs: number, timestamp: number): [number, number] => { - if (subsTw.aggregation.type === AggregationType.NONE) { + = (subsTw: SubscriptionTimewindow, endTs: number, timestamp: number, aggType?: AggregationType): [number, number] => { + if ((aggType || subsTw.aggregation.type) === AggregationType.NONE) { return [timestamp, timestamp]; } else { return calculateInterval(subsTw.startTs, endTs, subsTw.aggregation.interval, subsTw.tsOffset, subsTw.timezone, timestamp); diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 47605f108b..20f2062c77 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -359,6 +359,8 @@ export interface DataKey extends KeyInfo { _hash?: number; } +export type CellClickColumnInfo = Pick + export enum DataKeyConfigMode { general = 'general', advanced = 'advanced' @@ -686,6 +688,7 @@ export interface WidgetActionDescriptor extends WidgetAction { displayName?: string; useShowWidgetActionFunction?: boolean; showWidgetActionFunction?: string; + columnIndex?: number; } export const actionDescriptorToAction = (descriptor: WidgetActionDescriptor): WidgetAction => { @@ -696,6 +699,7 @@ export const actionDescriptorToAction = (descriptor: WidgetActionDescriptor): Wi delete result.displayName; delete result.useShowWidgetActionFunction; delete result.showWidgetActionFunction; + delete result.columnIndex; return result; }; diff --git a/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg b/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg new file mode 100644 index 0000000000..2ef3c8fc10 --- /dev/null +++ b/ui-ngx/src/assets/android-ios-stores-badges/android_store_en_black_badge.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_black_badge.svg b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_black_badge.svg new file mode 100755 index 0000000000..072b425a1a --- /dev/null +++ b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_black_badge.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_blk_4SVG_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_white_badge.svg b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_white_badge.svg new file mode 100755 index 0000000000..16c0496ca7 --- /dev/null +++ b/ui-ngx/src/assets/android-ios-stores-badges/ios_store_en_white_badge.svg @@ -0,0 +1,46 @@ + + Download_on_the_App_Store_Badge_US-UK_RGB_wht_092917 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui-ngx/src/assets/dashboard/sys_admin_home_page.json b/ui-ngx/src/assets/dashboard/sys_admin_home_page.json index a47103059e..24bac97279 100644 --- a/ui-ngx/src/assets/dashboard/sys_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/sys_admin_home_page.json @@ -377,38 +377,6 @@ "id": "867f82cf-ecf2-2d5c-35cb-08c6f2edc3a4", "typeFullFqn": "system.home_page_widgets.documentation_links" }, - "a23185ad-dc46-806c-0e50-5b21fb080ace": { - "type": "static", - "sizeX": 7.5, - "sizeY": 6.5, - "config": { - "datasources": [], - "timewindow": { - "realtime": { - "timewindowMs": 60000 - } - }, - "showTitle": false, - "backgroundColor": "rgb(255, 255, 255)", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "16px", - "settings": { - "columns": 3 - }, - "title": "Getting started", - "dropShadow": false, - "enableFullscreen": false, - "widgetStyle": {}, - "widgetCss": "", - "pageSize": 1024, - "noDataDisplayMessage": "", - "showLegend": false - }, - "row": 0, - "col": 0, - "id": "a23185ad-dc46-806c-0e50-5b21fb080ace", - "typeFullFqn": "system.home_page_widgets.getting_started" - }, "b35fa1b0-7f95-0b23-fade-d066653a40e1": { "type": "latest", "sizeX": 5, @@ -2640,6 +2608,94 @@ "row": 0, "col": 0, "id": "b9174a99-2a00-3dbe-0eae-927fd8506345" + }, + "c02d59a0-c979-5ec6-7f58-a5d89ff66236": { + "type": "static", + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "showTitle": false, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "16px", + "settings": { + "columns": 3 + }, + "title": "Getting started", + "dropShadow": false, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": "", + "noDataDisplayMessage": "" + }, + "row": 0, + "col": 0, + "id": "c02d59a0-c979-5ec6-7f58-a5d89ff66236", + "typeFullFqn": "system.home_page_widgets.getting_started" + }, + "53d5f2dd-d432-5362-745f-63bf4487dc96": { + "typeFullFqn": "system.mobile_app_qr_code", + "type": "static", + "sizeX": 6, + "sizeY": 3, + "config": { + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": {}, + "title": "{i18n:admin.mobile-app.connect-mobile-app}", + "dropShadow": false, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": ".tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title {\n padding: 0;\n}\n\n.tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title > .title-row > .title {\n padding-bottom: 12px;\n font-weight: 600;\n font-size: 20px;\n line-height: 24px;\n letter-spacing: 0.1px;\n color: rgba(0, 0, 0, 0.76);\n}\n\n@media screen and (min-width: 960px) and (max-width: 1819px) {\n .tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title > .title-row > .title {\n padding-bottom: 0;\n font-weight: 500;\n font-size: 14px;\n line-height: 20px;\n letter-spacing: 0.25px;\n }\n}\n", + "showTitleIcon": false, + "titleTooltip": "", + "titleStyle": null + }, + "row": 0, + "col": 0, + "id": "53d5f2dd-d432-5362-745f-63bf4487dc96" + }, + "0d34fa85-914f-30de-e3dd-7b7879f8d593": { + "typeFullFqn": "system.cards.markdown_card", + "type": "latest", + "sizeX": 5, + "sizeY": 3.5, + "config": { + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "useMarkdownTextFunction": true, + "markdownTextPattern": "### Markdown/HTML card\n - **Current entity**: ${entityName}.\n - **Current value**: ${Random}.", + "markdownTextFunction": "let html = '
';\nlet supportMobile = '${mobileQrEnabled}' || false;\nif (supportMobile && typeof supportMobile === 'boolean') {\n ctx.dashboardWidget.widgetLayout.mobileHide = false;\n html += '';\n} else {\n ctx.dashboardWidget.widgetLayout.mobileHide = true;\n html += '';\n}\nhtml += '
';\nreturn html;", + "applyDefaultMarkdownStyle": false, + "markdownCss": "" + }, + "title": "Select show mobile QR code", + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": { + "border": "none", + "boxShadow": "none", + "padding": 0 + }, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetCss": "", + "noDataDisplayMessage": "" + }, + "row": 0, + "col": 0, + "id": "0d34fa85-914f-30de-e3dd-7b7879f8d593" } }, "states": { @@ -2695,13 +2751,6 @@ "col": 0, "mobileHide": true }, - "a23185ad-dc46-806c-0e50-5b21fb080ace": { - "sizeX": 35, - "sizeY": 58, - "row": 0, - "col": 85, - "mobileHide": true - }, "163025d8-3ca4-dd1e-c17b-e40d8e03e8a8": { "sizeX": 44, "sizeY": 15, @@ -2709,6 +2758,14 @@ "mobileHeight": 7, "row": 0, "col": 0 + }, + "0d34fa85-914f-30de-e3dd-7b7879f8d593": { + "sizeX": 35, + "sizeY": 58, + "row": 0, + "col": 85, + "mobileHide": false, + "mobileHeight": 7 } }, "gridSettings": { @@ -2936,6 +2993,40 @@ } } } + }, + "mobile_app_qr_code": { + "name": "Mobile App QR code", + "root": false, + "layouts": { + "main": { + "widgets": { + "c02d59a0-c979-5ec6-7f58-a5d89ff66236": { + "sizeX": 120, + "sizeY": 45, + "row": 0, + "col": 0, + "mobileHide": true + }, + "53d5f2dd-d432-5362-745f-63bf4487dc96": { + "sizeX": 120, + "sizeY": 17, + "row": 45, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "columns": 120, + "margin": 12, + "outerMargin": false, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 20 + } + } + } } }, "entityAliases": { @@ -3051,4 +3142,4 @@ } }, "name": "System Administrator Home Page" -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index 85669b8bf4..40792caf09 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -101,38 +101,6 @@ "id": "867f82cf-ecf2-2d5c-35cb-08c6f2edc3a4", "typeFullFqn": "system.home_page_widgets.documentation_links" }, - "a23185ad-dc46-806c-0e50-5b21fb080ace": { - "type": "static", - "sizeX": 7.5, - "sizeY": 6.5, - "config": { - "datasources": [], - "timewindow": { - "realtime": { - "timewindowMs": 60000 - } - }, - "showTitle": false, - "backgroundColor": "rgb(255, 255, 255)", - "color": "rgba(0, 0, 0, 0.87)", - "padding": "16px", - "settings": { - "columns": 3 - }, - "title": "Getting started", - "dropShadow": false, - "enableFullscreen": false, - "widgetStyle": {}, - "widgetCss": "", - "pageSize": 1024, - "noDataDisplayMessage": "", - "showLegend": false - }, - "row": 0, - "col": 0, - "id": "a23185ad-dc46-806c-0e50-5b21fb080ace", - "typeFullFqn": "system.home_page_widgets.getting_started" - }, "ebbd0a6e-8a47-e770-5086-7f4974250f2d": { "type": "static", "sizeX": 7.5, @@ -1143,6 +1111,94 @@ "row": 0, "col": 0, "id": "8e71a398-caf5-540d-cec5-6e5dc264343e" + }, + "35bda856-2495-4f72-e35e-925bac8b8da7": { + "typeFullFqn": "system.cards.markdown_card", + "type": "latest", + "sizeX": 5, + "sizeY": 3.5, + "config": { + "showTitle": false, + "backgroundColor": "#fff", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "0px", + "settings": { + "useMarkdownTextFunction": true, + "markdownTextPattern": "### Markdown/HTML card\n - **Current entity**: ${entityName}.\n - **Current value**: ${Random}.", + "markdownTextFunction": "let html = '
';\nlet supportMobile = '${mobileQrEnabled}' || false;\nif (supportMobile && typeof supportMobile === 'boolean') {\n ctx.dashboardWidget.widgetLayout.mobileHide = false;\n html += '';\n} else {\n ctx.dashboardWidget.widgetLayout.mobileHide = true;\n html += '';\n}\nhtml += '
';\nreturn html;", + "applyDefaultMarkdownStyle": false, + "markdownCss": "" + }, + "title": "Select show mobile QR code", + "showTitleIcon": false, + "iconColor": "rgba(0, 0, 0, 0.87)", + "iconSize": "24px", + "titleTooltip": "", + "dropShadow": true, + "enableFullscreen": false, + "widgetStyle": { + "border": "none", + "boxShadow": "none", + "padding": 0 + }, + "titleStyle": { + "fontSize": "16px", + "fontWeight": 400 + }, + "widgetCss": "", + "noDataDisplayMessage": "" + }, + "row": 0, + "col": 0, + "id": "35bda856-2495-4f72-e35e-925bac8b8da7" + }, + "ed33e56f-293b-aaaa-ad19-0cf499dc926f": { + "type": "static", + "sizeX": 7.5, + "sizeY": 6.5, + "config": { + "showTitle": false, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "16px", + "settings": { + "columns": 3 + }, + "title": "Getting started", + "dropShadow": false, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": "", + "noDataDisplayMessage": "" + }, + "row": 0, + "col": 0, + "id": "ed33e56f-293b-aaaa-ad19-0cf499dc926f", + "typeFullFqn": "system.home_page_widgets.getting_started" + }, + "8c5207d8-7103-4dc2-db48-d61c0c8c61fd": { + "typeFullFqn": "system.mobile_app_qr_code", + "type": "static", + "sizeX": 6, + "sizeY": 3, + "config": { + "showTitle": true, + "backgroundColor": "rgb(255, 255, 255)", + "color": "rgba(0, 0, 0, 0.87)", + "padding": "8px", + "settings": {}, + "title": "{i18n:admin.mobile-app.connect-mobile-app}", + "dropShadow": false, + "enableFullscreen": false, + "widgetStyle": {}, + "widgetCss": ".tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title {\n padding: 0;\n}\n\n.tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title > .title-row > .title {\n padding-bottom: 12px;\n font-weight: 600;\n font-size: 20px;\n line-height: 24px;\n letter-spacing: 0.1px;\n color: rgba(0, 0, 0, 0.76);\n}\n\n@media screen and (min-width: 960px) and (max-width: 1819px) {\n .tb-widget-container > .tb-widget > .tb-widget-header > .tb-widget-title > .title-row > .title {\n padding-bottom: 0;\n font-weight: 500;\n font-size: 14px;\n line-height: 20px;\n letter-spacing: 0.25px;\n }\n}\n", + "showTitleIcon": false, + "titleTooltip": "", + "titleStyle": null + }, + "row": 0, + "col": 0, + "id": "8c5207d8-7103-4dc2-db48-d61c0c8c61fd" } }, "states": { @@ -1167,13 +1223,6 @@ "col": 28, "mobileHide": true }, - "a23185ad-dc46-806c-0e50-5b21fb080ace": { - "sizeX": 35, - "sizeY": 58, - "row": 0, - "col": 85, - "mobileHide": true - }, "ebbd0a6e-8a47-e770-5086-7f4974250f2d": { "sizeX": 28, "sizeY": 16, @@ -1212,6 +1261,14 @@ "row": 0, "col": 43, "mobileOrder": 0 + }, + "35bda856-2495-4f72-e35e-925bac8b8da7": { + "sizeX": 35, + "sizeY": 58, + "row": 0, + "col": 85, + "mobileHide": false, + "mobileHeight": 10 } }, "gridSettings": { @@ -1281,6 +1338,40 @@ } } } + }, + "mobile_app_qr_code": { + "name": "Mobile App QR code", + "root": false, + "layouts": { + "main": { + "widgets": { + "ed33e56f-293b-aaaa-ad19-0cf499dc926f": { + "sizeX": 120, + "sizeY": 45, + "row": 0, + "col": 0, + "mobileHide": true + }, + "8c5207d8-7103-4dc2-db48-d61c0c8c61fd": { + "sizeX": 120, + "sizeY": 17, + "row": 45, + "col": 0 + } + }, + "gridSettings": { + "backgroundColor": "#eeeeee", + "columns": 120, + "margin": 12, + "outerMargin": false, + "backgroundSizeMode": "100%", + "autoFillHeight": true, + "backgroundImageUrl": null, + "mobileAutoFillHeight": false, + "mobileRowHeight": 20 + } + } + } } }, "entityAliases": { @@ -1416,4 +1507,4 @@ } }, "name": "Tenant Administrator Home Page" -} \ No newline at end of file +} diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/expressions_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/expressions_fn.md new file mode 100644 index 0000000000..d04dcbd5eb --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/expressions_fn.md @@ -0,0 +1,96 @@ +### Expressions +#### JSON Path: + +The expression field is used to extract data from the MQTT message. There are various available options for different parts of the messages: + + - The JSONPath format can be used to extract data from the message body. + + - The regular expression format can be used to extract data from the topic where the message will arrive. + + - Slices can only be used in the expression fields of bytes converters. + +JSONPath expressions specify the items within a JSON structure (which could be an object, array, or nested combination of both) that you want to access. These expressions can select elements from JSON data on specific criteria. Here's a basic overview of how JSONPath expressions are structured: + +- `$`: The root element of the JSON document; + +- `.`: Child operator used to select child elements. For example, $.store.book ; + +- `[]`: Child operator used to select child elements. $['store']['book'] accesses the book array within a store object; + +##### Examples: + +For example, if we want to extract the device name from the following message, we can use the expression below: + +MQTT message: + +``` +{ + "sensorModelInfo": { + "sensorName": "AM-123", + "sensorType": "myDeviceType" + }, + "data": { + "temp": 12.2, + "hum": 56, + "status": "ok" + } +} +{:copy-code} +``` + +Expression: + +`${sensorModelInfo.sensorName}` + +Converted data: + +`AM-123` + +If we want to extract all data from the message above, we can use the following expression: + +`${data}` + +Converted data: + +`{"temp": 12.2, "hum": 56, "status": "ok"}` + +Or if we want to extract specific data (for example “temperature”), you can use the following expression: + +`${data.temp}` + +And as a converted data we will get: + +`12.2` + +
+ +#### Regular expressions for topic: + +Device name or device profile can be parsed from the MQTT topic using regular expression. A regular expression, often abbreviated as regex or regexp, is a sequence of characters that forms a search pattern, primarily used for string matching and manipulation. + +##### Regular expression for topic examples: + +| Topic | Regular expression | Output data | Description | +|:---------------------------|----------------------------------|--------------------------|--------------------------------------| +| /devices/AM123/mytype/data | /devices/([^/]+)/mytype/data | AM123 | Getting device name from topic | +| /devices/AM123/mytype/data | /devices/[A-Z0-9]+/([^/]+)/data | mytype | Getting device profile from topic | + +
+ +#### Bytes converter: + +For bytes converter, expression fields can use slices format only. A slice specifies how to slice a sequence, determining the start point, and the endpoint. Here's a basic overview of slice components: + +- `start`: The starting index of the slice. It is included in the slice. If omitted, slicing starts at the beginning of the sequence. Indexing starts at 0, so the first element of the sequence is at index 0. + +- `stop`: The ending index of the slice. It is excluded from the slice, meaning the slice will end just before this index. If omitted, slicing goes through the end of the sequence. + +##### Bytes parsing examples: + + +| Message body | Slice | Output data | Description | +|:-----------------------|-----------------|--------------------------|------------------------------| +| AM123,mytype,12.2,45 | [:5] | AM123 | Extracting device name | +| AM123,mytype,12.2,45 | [:] | AM123,mytype,12.2,45 | Extracting all data | +| AM123,mytype,12.2,45 | [18:] | 45 | Extracting humidity value | +| AM123,mytype,12.2,45 | [13:17] | 12.2 | Extracting temperature value | diff --git a/ui-ngx/src/assets/help/en_US/widget/lib/gateway/topic-filter_fn.md b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/topic-filter_fn.md new file mode 100644 index 0000000000..6ecf2f30a3 --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/widget/lib/gateway/topic-filter_fn.md @@ -0,0 +1,15 @@ +### Topic filter + +The `topicFilter` supports special symbols: `#` and `+`, allowing to subscribe to multiple topics. + +Also, the MQTT connector supports shared subscriptions. To create a shared subscription you need to add `$share/` +as a prefix for the topic filter and shared subscription group name. For example to subscribe to the my-shared-topic +in group **my-group-name** you can set the topic filter to “$share/**my-group-name**/my-shared-topic”. + + +#### Examples: + +| Topic | Topic filter | Payload | Description | +|:-----------------------|----------------------------------|---------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------| +| sensor/data | sensor/data | `{"serialNumber": "SN-001", "sensorType": "Thermometer", "sensorModel": "T1000", "temp": 42, "hum": 58}` | Device Name is part of the payload, to extract it you need to use JSON Path expression | +| sensor/SN-001/data | sensor/+/data | `{"sensorType": "Thermometer", "sensorModel": "T1000", "temp": 42, "hum": 58}` | Device Name is part of the topic, to extract it you need to use regular expression. | diff --git a/ui-ngx/src/assets/locale/locale.constant-ar_AE.json b/ui-ngx/src/assets/locale/locale.constant-ar_AE.json index 2c552d6ab6..ec00225e6a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ar_AE.json +++ b/ui-ngx/src/assets/locale/locale.constant-ar_AE.json @@ -2078,8 +2078,7 @@ "step": "خطوة", "min-evaluation-period": "الفترة الدنيا للتقييم", "max-evaluation-period": "الفترة القصوى للتقييم" - }, - "composite-operations-support": "يدعم عمليات القراءة/الكتابة/المراقبة المركبة" + } }, "snmp": { "add-communication-config": "إضافة تكوين الاتصال", diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index e16dcee50b..fcb0021ddc 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -419,6 +419,36 @@ "no-auto-commit-entities-prompt": "No entities configured for auto-commit", "delete-auto-commit-settings-title": "Are you sure you want to delete auto-commit settings?", "delete-auto-commit-settings-text": "Be careful, after the confirmation the auto-commit settings will be removed and auto-commit will be disabled for all entities.", + "mobile-app": { + "mobile-app": "Mobile app", + "mobile-app-qr-code-widget-settings": "Mobile app QR code widget settings", + "applications": "Applications", + "default": "Default", + "custom": "Custom", + "android": "Android", + "app-package-name": "App package name", + "app-package-name-required": "App package name is required", + "sha256-certificate-fingerprints": "SHA256 certificate fingerprints", + "sha256-certificate-fingerprints-required": "SHA256 certificate fingerprints is required", + "ios": "iOS", + "app-id": "App ID", + "app-id-required": "App ID is required", + "appearance": "Appearance", + "appearance-on-home-page": "Appearance on Home page", + "enabled": "Enabled", + "disabled": "Disabled", + "badges": "Badges", + "original": "Original", + "white": "White", + "label": "Label", + "label-required": "Label is required", + "right": "Right", + "left": "Left", + "set": "Set", + "preview": "Preview", + "connect-mobile-app": "Connect mobile app", + "use-system-settings": "Use system settings" + }, "2fa": { "2fa": "Two-factor authentication", "available-providers": "Available providers", @@ -1899,6 +1929,11 @@ "step": "Step", "min-evaluation-period": "Minimum evaluation period", "max-evaluation-period": "Maximum evaluation period" + }, + "default-object-id": "Default Object Version (Attribute)", + "default-object-id-ver": { + "v1-0": "1.0", + "v1-1": "1.1" } }, "snmp": { @@ -2709,11 +2744,38 @@ }, "gateway": { "add-entry": "Add configuration", + "add-attribute": "Add attribute", + "add-key": "Add key", + "add-timeseries": "Add time series", + "add-mapping": "Add mapping", "advanced": "Advanced", + "attributes": "Attributes", + "attribute-filter": "Attribute filter", + "attribute-filter-hint": "Filter for incoming attribute name from platform, supports regular expression.", + "attribute-filter-required": "Attribute filter required.", + "attribute-name-expression": "Attribute name expression", + "attribute-name-expression-required": "Attribute name expression required.", + "attribute-name-expression-hint": "Hint for Attribute name expression", + "basic": "Basic", + "broker": { + "connection": "Connection to broker", + "name": "Broker name", + "name-required": "Broker name required.", + "security-types": { + "anonymous": "Anonymous", + "basic": "Basic", + "certificates": "Certificates" + } + }, + "CA-certificate-path": "Path to CA certificate file", + "path-to-CA-cert-required": "Path to CA certificate file is required.", + "change-connector-title": "Confirm connector change", + "change-connector-text": "Switching connectors will discard any unsaved changes. Continue?", "checking-device-activity": "Checking device activity", "command": "Docker commands", "command-copied-message": "Docker command has been copied to clipboard", "configuration": "Configuration", + "add-connector": "Add connector", "connector-add": "Add new connector", "connector-enabled": "Enable connector", "connector-name": "Connector name", @@ -2729,6 +2791,9 @@ "connectors-table-actions": "Actions", "connectors-table-key": "Key", "connectors-table-class": "Class", + "copy-username": "Copy username", + "copy-password": "Copy password", + "copy-client-id": "Copy client ID", "connector-created": "Connector created.", "connector-updated": "Connector updated.", "rpc-command-save-template": "Save Template", @@ -2738,10 +2803,30 @@ "gateway-configuration": "General Configuration", "docker-label": "Use the following instruction to run IoT Gateway in Docker compose with credentials for selected device", "install-docker-compose": "Use the instructions to download, install and setup docker compose", + "device-info-settings": "Device info settings", + "device-info": { + "entity-field": "Entity field", + "source": "Source", + "expression": "Value / Expression", + "expression-hint": "Show help", + "name": "Name", + "profile-name": "Profile name", + "device-name-expression": "Device name expression", + "device-name-expression-required": "Device name expression required.", + "device-profile-expression-required": "Device profile expression required." + }, + "device-name-filter": "Device name filter", + "device-name-filter-hint": "This field supports Regular expressions to filter incoming data by device name.", + "device-name-filter-required": "Device name filter is required.", + "details": "Details", + "delete-mapping-title": "Delete mapping ?", "download-configuration-file": "Download configuration file", "download-docker-compose": "Download docker-compose.yml for your gateway", + "enable-remote-logging": "Enable remote logging", + "ellipsis-chips-text": "+ {{count}} more", "launch-gateway": "Launch gateway", "launch-docker-compose": "Start the gateway using the following command in the terminal from folder with docker-compose.yml file", + "logs-configuration": "Logs configuration", "create-new-gateway": "Create a new gateway", "create-new-gateway-text": "Are you sure you want create a new gateway with name: '{{gatewayName}}'?", "created-time": "Created time", @@ -2750,14 +2835,46 @@ "configuration-delete-dialog-input": "Gateway name", "configuration-delete-dialog-input-required": "Gateway name is mandatory", "configuration-delete-dialog-confirm": "Turn Off", + "connector-duplicate-name": "Connector with such name already exists.", + "connector-side": "Connector side", + "payload-type": "Payload type", + "platform-side": "Platform side", + "JSON": "JSON", + "JSON-hint": "Converter for this payload type processes MQTT messages in JSON format. It uses JSON Path expressions to extract vital details such as device names, device profile names, attributes, and time series from the message. And regular expressions to get device details from topics.", + "bytes": "Bytes", + "bytes-hint": "Converter for this payload type designed for binary MQTT payloads, this converter directly interprets binary data to retrieve device names and device profile names, along with attributes and time series, using specific byte positions for data extraction.", + "custom": "Custom", + "custom-hint": "This option allows you to use a custom converter for specific data tasks. You need to add your custom converter to the extension folder and enter its class name in the UI settings. Any keys you provide will be sent as configuration to your custom converter.", + "client-cert-path": "Path to client certificate file", + "path-to-client-cert-required": "Path to client certificate file is required.", + "client-id": "Client ID", + "data-conversion": "Data conversion", + "data-mapping": "Data mapping", + "data-mapping-hint": "Data mapping provides the capability to parse and convert the data received from a MQTT client in incoming messages into specific attributes and time series data keys.", "delete": "Delete configuration", + "delete-attribute": "Delete attribute", + "delete-key": "Delete key", + "delete-timeseries": "Delete time series", + "default": "Default", "download-tip": "Download configuration file", "drop-file": "Drop file here or", + "extension": "Extension", + "extension-hint": "Put your converter classname in the field. Custom converter with such class should be in extension/mqtt folder.", + "extension-required": "Extension is required.", + "extension-configuration": "Extension configuration", + "extension-configuration-hint": "Configuration for convertor", + "fill-connector-defaults": "Fill configuration with default values", + "fill-connector-defaults-hint": "This property allows to fill connector configuration with default values on it's creation.", + "from-device-request-settings": "Input request parsing", + "from-device-request-settings-hint": "These fields support JSONPath expressions to extract a name from incoming message.", + "to-device-response-settings": "Output request processing", + "to-device-response-settings-hint": "For these fields you can use the following variables and they will be replaced with actual values: ${deviceName}, ${attributeKey}, ${attributeValue}", "gateway": "Gateway", "gateway-exists": "Device with same name is already exists.", "gateway-name": "Gateway name", "gateway-name-required": "Gateway name is required.", "gateway-saved": "Gateway configuration successfully saved.", + "generate-client-id": "Generate Client ID", "grpc": "GRPC", "grpc-keep-alive-timeout": "Keep alive timeout (in ms)", "grpc-keep-alive-timeout-required": "Keep alive timeout is required", @@ -2787,8 +2904,11 @@ "inactivity-timeout-seconds-required": "Inactivity timeout is required", "inactivity-timeout-seconds-min": "Inactivity timeout can not be less then 1", "inactivity-timeout-seconds-pattern": "Inactivity timeout is not valid", + "host": "Host", + "host-required": "Host is required.", "json-parse": "Not valid JSON.", "json-required": "Field cannot be empty.", + "JSONPath-hint": "This field supports constants and JSONPath expressions.", "logs": { "logs": "Logs", "days": "days", @@ -2813,16 +2933,39 @@ "backup-count-min": "Backup count can not be less then 1", "backup-count-required": "Backup count required" }, + "max-number-of-workers": "Max number of workers", + "max-number-of-workers-hint": "Maximal number of workers threads for converters \n(The amount of workers changes dynamically, depending on load) \nRecommended amount 50-150.", + "max-number-of-workers-required": "Max number of workers is required.", + "max-messages-queue-for-worker": "Max messages queue per worker", + "max-messages-queue-for-worker-hint": "Maximal messages count that will be in the queue \nfor each converter worker.", + "max-messages-queue-for-worker-required": "Max messages queue per worker is required.", "min-pack-send-delay": "Min pack send delay (in ms)", "min-pack-send-delay-required": "Min pack send delay is required", "min-pack-send-delay-min": "Min pack send delay can not be less then 0", + "mqtt-version": "MQTT version", + "name": "Name", + "name-required": "Name is required.", + "no-attributes": "No attributes", "no-connectors": "No connectors", "no-data": "No configurations", "no-gateway-found": "No gateway found.", "no-gateway-matching": " '{{item}}' not found.", + "no-timeseries": "No time series", + "no-keys": "No keys", + "path-hint": "The path is local to the gateway file system", "path-logs": "Path to log files", "path-logs-required": "Path is required.", + "password": "Password", + "password-required": "Password is required.", "permit-without-calls": "Keep alive permit without calls", + "port": "Port", + "port-required": "Port is required.", + "port-limits-error": "Port should be number from {{min}} to {{max}}.", + "private-key-path": "Path to private key file", + "path-to-private-key-required": "Path to private key file is required.", + "raw": "Raw", + "retain": "Retain", + "retain-hint": "This flag tells the broker to store the message for a topic\nand ensures any new client subscribing to that topic\nwill receive the stored message.", "remote": "Remote configuration", "remote-logging-level": "Logging level", "remove-entry": "Remove configuration", @@ -2918,8 +3061,36 @@ "write-multiple-holding-registers": "16: Write Multiple Holding Registers", "json-value-invalid": "JSON value has an invalid format" }, + "request" : { + "connect-request": "Connect request", + "disconnect-request": "Disconnect request", + "attribute-request": "Attribute request", + "attribute-update": "Attribute update", + "rpc-connection": "RPC command" + }, + "request-type": "Request type", + "requests-mapping": "Requests mapping", + "requests-mapping-hint": "MQTT Connector requests allows you to connect, disconnect, process attribute requests from the device, handle attribute updates on the server and RPC processing configuration.", + "request-topic-expression": "Request topic expression", + "request-topic-expression-required": "Request topic expression is required.", + "response-timeout": "Response timeout (ms)", + "response-timeout-required": "Response timeout is required.", + "response-timeout-limits-error": "Timeout must be more then {{min}} ms.", + "response-topic-Qos": "Response topic QoS", + "response-topic-Qos-hint": "MQTT Quality of Service (QoS) is an agreement between the message sender and receiver that defines the level of delivery guarantee for a specific message.", + "response-topic-expression": "Response topic expression", + "response-topic-expression-required": "Response topic expression is required.", + "response-value-expression": "Response value expression", + "response-value-expression-required": "Response value expression is required.", + "value": "Value", + "value-required": "Value is required.", + "value-expression": "Value expression", + "value-expression-required": "Value expression is required.", + "with-response": "With response", + "without-response": "Without response", "other": "Other", "save-tip": "Save configuration file", + "security": "Security", "security-type": "Security type", "security-types": { "access-token": "Access Token", @@ -2928,11 +3099,15 @@ "tls-access-token": "TLS + Access Token", "tls-private-key": "TLS + Private Key" }, + "select-connector": "Select connector to display config", + "send-change-data": "Send data only on change", + "send-change-data-hint": "The values will be saved to the database only if they are different from the corresponding values in the previous converted message. This functionality applies to both attributes and time series in the converter output.", "server-port": "Server port", + "set": "Set", "statistics": { "statistic": "Statistic", "statistics": "Statistics", - "statistic-commands-empty": "No statistics available", + "statistic-commands-empty": "No configured statistic keys found. You can configure them in \"Statistics\" tab in general configuration.", "commands": "Commands", "send-period": "Statistic send period (in sec)", "send-period-required": "Statistic send period is required", @@ -2987,11 +3162,21 @@ "memory-storage": "Memory storage", "sqlite": "SQLITE" }, + "source-type": { + "msg": "Extract from message", + "topic": "Extract from topic", + "const": "Constant" + }, + "workers-settings": "Workers settings", "thingsboard": "ThingsBoard", "general": "General", - "thingsboard-host": "ThingsBoard host", + "timeseries": "Time series", + "key": "Key", + "keys": "Keys", + "key-required": "Key is required.", + "thingsboard-host": "Platform host", "thingsboard-host-required": "Host is required.", - "thingsboard-port": "ThingsBoard port", + "thingsboard-port": "Platform port", "thingsboard-port-max": "Maximum port number is 65535.", "thingsboard-port-min": "Minimum port number is 1.", "thingsboard-port-pattern": "Port is not valid.", @@ -2999,8 +3184,14 @@ "tidy": "Tidy", "tidy-tip": "Tidy config JSON", "title-connectors-json": "Connector {{typeName}} configuration", + "type": "Type", + "topic-filter": "Topic filter", + "topic-required": "Topic filter is required.", "tls-path-ca-certificate": "Path to CA certificate on gateway", "tls-path-client-certificate": "Path to client certificate on gateway", + "method-filter": "Method filter", + "method-filter-hint": "Regular expression to filter incoming RPC method from platform.", + "method-filter-required": "Method filter is required.", "messages-ttl-check-in-hours": "Messages TTL check in hours", "messages-ttl-check-in-hours-required": "Messages TTL check in hours is required.", "messages-ttl-check-in-hours-min": "Min number is 1.", @@ -3012,19 +3203,26 @@ "mqtt-qos": "QoS", "mqtt-qos-required": "QoS is required", "mqtt-qos-range": "QoS values range is from 0 to 1", + "qos": { + "at-most-once": "0 - At most once", + "at-least-once": "1 - At least once", + "exactly-once": "2 - Exactly once" + }, "tls-path-private-key": "Path to private key on gateway", "toggle-fullscreen": "Toggle fullscreen", "transformer-json-config": "Configuration JSON*", "update-config": "Add/update configuration JSON", + "username": "Username", + "username-required": "Username is required.", "hints": { "remote-configuration": "Enables remote configuration and management of the gateway", "remote-shell": "Enables remote control of the operating system with the gateway from the Remote Shell widget", - "host": "Hostname or IP address of ThingsBoard server", - "port": "Port of MQTT service on ThingsBoard server", - "token": "Access token for the gateway from ThingsBoard server", - "client-id": "MQTT client id for the gateway form ThingsBoard server", - "username": "MQTT username for the gateway form ThingsBoard server", - "password": "MQTT password for the gateway form ThingsBoard server", + "host": "Hostname or IP address of platform server", + "port": "Port of MQTT service on platform server", + "token": "Access token for the gateway from platform server", + "client-id": "MQTT client id for the gateway form platform server", + "username": "MQTT username for the gateway form platform server", + "password": "MQTT password for the gateway form platform server", "ca-cert": "Path to CA certificate file", "date-form": "Date format in log message", "data-folder": "Path to folder, that will contains data (Relative or Absolute)", @@ -3033,10 +3231,10 @@ "backup-count": "If backup count is > 0, when a rollover is done, no more than backup count files are kept - the oldest ones are deleted", "storage": "Provides configuration for saving incoming data before it is sent to the platform", "max-file-count": "Maximum count of file that will be created", - "max-read-count": "Count of messages to get from storage and send to ThingsBoard", + "max-read-count": "Count of messages to get from storage and send to platform", "max-records": "Maximum count of records that will be stored in one file", - "read-record-count": "Count of messages to get from storage and send to ThingsBoard", - "max-records-count": "Maximum count of data in storage before send to ThingsBoard", + "read-record-count": "Count of messages to get from storage and send to platform", + "max-records-count": "Maximum count of data in storage before send to platform", "ttl-check-hour": "How often will Gateway check data for obsolescence", "ttl-messages-day": "Maximum days that storage will save data", "commands": "Commands for collecting additional statistic", @@ -3054,7 +3252,10 @@ "grpc-min-time-between-pings": "Minimum amount of time the server should wait between sending keepalive ping messages", "grpc-max-pings-without-data": "Maximum number of keepalive ping messages that the server can send without receiving any data before it considers the connection dead.", "grpc-min-ping-interval-without-data": "Minimum amount of time the server should wait between sending keepalive ping messages when there is no data being sent or received.", - "permit-without-calls": "Allow server to keep the GRPC connection alive even when there are no active RPC calls." + "permit-without-calls": "Allow server to keep the GRPC connection alive even when there are no active RPC calls.", + "memory": "Your data will be stored in the in-memory queue, it is a fastest but no persistence guarantee.", + "file": "Your data will be stored in separated files and will be saved even after the gateway restart.", + "sqlite": "Your data will be stored in file based database. And will be saved even after the gateway restart." } }, "grid": { @@ -4238,6 +4439,9 @@ "transport-device-msg-rate-limit": "Transport device messages", "transport-device-telemetry-msg-rate-limit": "Transport device telemetry messages", "transport-device-telemetry-data-points-rate-limit": "Transport device telemetry data points", + "transport-gateway-msg-rate-limit": "Transport gateway messages", + "transport-gateway-telemetry-msg-rate-limit": "Transport gateway telemetry messages", + "transport-gateway-telemetry-data-points-rate-limit": "Transport gateway telemetry data points", "tenant-entity-export-rate-limit": "Entity version creation", "tenant-entity-import-rate-limit": "Entity version load", "tenant-notification-request-rate-limit": "Notification requests", @@ -4319,6 +4523,9 @@ "edit-transport-device-msg-title": "Edit transport device messages rate limits", "edit-transport-device-telemetry-msg-title": "Edit transport device telemetry messages rate limits", "edit-transport-device-telemetry-data-points-title": "Edit transport device telemetry data points rate limits", + "edit-transport-gateway-msg-title": "Edit transport gateway messages rate limits", + "edit-transport-gateway-telemetry-msg-title": "Edit transport gateway telemetry messages rate limits", + "edit-transport-gateway-telemetry-data-points-title": "Edit transport gateway telemetry data points rate limits", "edit-tenant-rest-limits-title": "Edit REST requests for tenant rate limits", "edit-customer-rest-limits-title": "Edit REST requests for customer rate limits", "edit-ws-limit-updates-per-session-title": "Edit WS updates per session rate limits", @@ -4352,6 +4559,9 @@ "transport-device-msg": "Transport device messages", "transport-device-telemetry-msg": "Transport device telemetry messages", "transport-device-telemetry-data-points": "Transport device telemetry data points", + "transport-gateway-msg": "Transport gateway messages", + "transport-gateway-telemetry-msg": "Transport gateway telemetry messages", + "transport-gateway-telemetry-data-points": "Transport gateway telemetry data points", "sec": "sec" } }, @@ -5295,6 +5505,10 @@ "action-source": "Action source", "select-action-source": "Select action source", "action-source-required": "Action source is required.", + "column-index": "Column index", + "select-column-index": "Select column index", + "column-index-required": "Column index is required.", + "not-set": "Not set", "action-name": "Name", "action-name-required": "Action name is required.", "action-name-not-unique": "Another action with the same name already exists.\nAction name should be unique within the same action source.", @@ -7299,6 +7513,7 @@ "widget-action": { "action-cell-button": "Action cell button", "row-click": "On row click", + "cell-click": "On cell click", "polygon-click": "On polygon click", "marker-click": "On marker click", "circle-click": "On circle click", @@ -7307,6 +7522,7 @@ "element-click": "On HTML element click", "pie-slice-click": "On slice click", "row-double-click": "On row double click", + "cell-double-click": "On cell double click", "card-click": "On card click", "click": "On click" } @@ -7336,6 +7552,7 @@ "ja_JP": "日本語", "ka_GE": "ქართული", "ko_KR": "한국어", + "lt_LT": "Lietuvių", "lv_LV": "Latviešu", "nl_BE": "Koninkrijk België", "pl_PL": "Polski", diff --git a/ui-ngx/src/assets/locale/locale.constant-lt_LT.json b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json new file mode 100644 index 0000000000..5ca808146c --- /dev/null +++ b/ui-ngx/src/assets/locale/locale.constant-lt_LT.json @@ -0,0 +1,7954 @@ +{ + "access": { + "unauthorized": "Neautorizuotas", + "unauthorized-access": "Neautorizuota prieiga", + "unauthorized-access-text": "Prieiga prie šio funkcionalumo leidžiama tik prisijungusiam vartotojui!", + "access-forbidden": "Prieiga uždrausta", + "access-forbidden-text": "Neturite teisių prieigai!
Bandykite prisijungti kitu vartotoju.", + "refresh-token-expired": "Sesijos galiojimas baigėsi", + "refresh-token-failed": "Sesijos atnaujinti nepavyksta", + "permission-denied": "Leidimas nesuteiktas", + "permission-denied-text": "Šio veiksmo atlikti negalite!" + }, + "account": { + "account": "Account", + "notification-settings": "Notification settings" + }, + "action": { + "activate": "Aktyvuoti", + "suspend": "Sustabdyti", + "save": "Išsaugoti", + "saveAs": "Išsaugoti kaip", + "move": "Perkelti", + "cancel": "Atmesti", + "ok": "OK", + "delete": "Panaikinti", + "add": "Pridėti", + "yes": "Taip", + "no": "Ne", + "update": "Atnaujinti", + "remove": "Panaikinti", + "search": "Paieška", + "clear-search": "Išvalyti", + "assign": "Priskirti", + "unassign": "Atsieti", + "share": "Pasidalinti", + "make-private": "Riboti matomumą", + "make-public": "Neriboti matomumo", + "apply": "Taikyti", + "apply-changes": "Pritaikyti pakeitimus", + "edit-mode": "Redagavimo režimas", + "enter-edit-mode": "Redagavimo režimas", + "decline-changes": "Atšaukti pakeitimus", + "open": "Atverti", + "decline": "Atšaukti", + "close": "Uždaryti", + "back": "Atgal", + "run": "Paleisti", + "sign-in": "Prisijunkite!", + "edit": "Redaguoti", + "view": "Peržiūrėti", + "create": "Kurti", + "drag": "Vilkti", + "refresh": "Atnaujinti", + "undo": "Atšaukti", + "copy": "Kopijuoti", + "paste": "Įklijuoti", + "copy-reference": "Kopijuoti nuorodą", + "paste-reference": "Įklijuoti nuorodą", + "import": "Importuoti", + "export": "Eksportuoti", + "share-via": "Pasidalinti per {{provider}}", + "select": "Pasirinkti", + "continue": "Tęsti", + "discard-changes": "Atsisakyti pakeitimų", + "download": "Parsisiųsti", + "next": "Sekantis", + "next-with-label": "Sekantis: {{label}}", + "read-more": "Skaityti daugiau", + "hide": "Paslėpti", + "done": "Atlikta", + "print": "Spausdinti", + "restore": "Atkurti", + "confirm": "Patvirtinti", + "more": "Daugiau", + "less": "Mažiau", + "skip": "Praleisti", + "send": "Siųsti", + "reset": "Nustatyti iš naujo", + "show-more": "Rodyti daugiau", + "dont-show-again": "Daugiau neberodyti", + "see-documentation": "Žiūrėti dokumentacijoje", + "clear": "Išvalyti" + }, + "aggregation": { + "aggregation": "Agregavimas", + "function": "Duomenų aggregavimo funkcija", + "limit": "Maksimali reikšmė", + "group-interval": "Grupavimo intervalas", + "min": "Min", + "max": "Max", + "avg": "Vidurkis", + "sum": "Sum", + "count": "Kiekis", + "none": "Nėra" + }, + "admin": { + "settings": "Settings", + "general": "General", + "general-settings": "General Settings", + "home-settings": "Home Settings", + "home": "Home", + "outgoing-mail": "Mail Server", + "outgoing-mail-settings": "Outgoing Mail Server Settings", + "system-settings": "System Settings", + "test-mail-sent": "Test mail was successfully sent!", + "base-url": "Base URL", + "base-url-required": "Base URL is required.", + "prohibit-different-url": "Prohibit to use hostname from the client request headers", + "prohibit-different-url-hint": "This setting should be enabled for production environments. May cause security issues when disabled", + "mail-from": "Mail From", + "mail-from-required": "Mail From is required.", + "smtp-protocol": "SMTP protocol", + "smtp-host": "SMTP host", + "smtp-host-required": "SMTP host is required.", + "smtp-port": "SMTP port", + "smtp-port-required": "You must supply a smtp port.", + "smtp-port-invalid": "That doesn't look like a valid smtp port.", + "timeout-msec": "Timeout (msec)", + "timeout-required": "Timeout is required.", + "timeout-invalid": "That doesn't look like a valid timeout.", + "enable-tls": "Enable TLS", + "tls-version": "TLS version", + "enable-proxy": "Enable proxy", + "proxy-host": "Proxy host", + "proxy-host-required": "Proxy host is required.", + "proxy-port": "Proxy port", + "proxy-port-required": "Proxy port is required.", + "proxy-port-range": "Proxy port should be in a range from 1 to 65535.", + "proxy-user": "Proxy user", + "proxy-password": "Proxy password", + "change-password": "Change password", + "send-test-mail": "Send test mail", + "use-system-mail-settings": "Use System Mail Server Settings", + "mail-templates": "Mail Templates", + "mail-template-settings": "Mail Templates Settings", + "use-system-mail-template-settings": "Use System Mail Templates", + "mail-template": { + "mail-template": "Mail template", + "test": "Test email message", + "activation": "Account activation message", + "account-activated": "Account activated message", + "account-lockout": "Account lockout message", + "reset-password": "Reset password message", + "password-was-reset": "Password was reset message", + "user-activated": "User activated message", + "user-registered": "User registered message", + "api-usage-state-enabled": "Api usage state enabled", + "api-usage-state-warning": "Api usage state warning", + "api-usage-state-disabled": "Api usage state disabled", + "two-fa-verification": "2FA verification message" + }, + "mail-subject": "Mail Subject", + "mail-body": "Mail body", + "sms-provider": "SMS provider", + "sms-provider-settings": "SMS provider settings", + "use-system-sms-settings": "Use System SMS provider settings", + "sms-provider-type": "SMS provider type", + "sms-provider-type-required": "SMS provider type is required.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID is required", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key is required", + "aws-region": "AWS Region", + "aws-region-required": "AWS Region is required", + "number-from": "Phone Number From", + "number-from-required": "Phone Number From is required.", + "number-to": "Phone Number To", + "number-to-required": "Phone Number To is required.", + "phone-number-hint": "Phone Number in E.164 format, ex. +19995550123", + "phone-number-hint-twilio": "Phone Number in E.164 format/Phone Number's SID/Messaging Service SID, ex. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Invalid phone number. Should be in E.164 format, ex. +19995550123.", + "phone-number-pattern-twilio": "Invalid phone number. Should be in E.164 format/Phone Number's SID/Messaging Service SID, ex. +19995550123/PNXXX/MGXXX.", + "sms-message": "SMS message", + "sms-message-required": "SMS message is required.", + "sms-message-max-length": "SMS message can't be longer 1600 characters", + "twilio-account-sid": "Twilio Account SID", + "twilio-account-sid-required": "Twilio Account SID is required", + "twilio-account-token": "Twilio Account Token", + "twilio-account-token-required": "Twilio Account Token is required", + "send-test-sms": "Send test SMS", + "test-sms-sent": "Test SMS was successfully sent!", + "security-settings": "Security settings", + "password-policy": "Password policy", + "minimum-password-length": "Minimum password length", + "minimum-password-length-required": "Minimum password length is required", + "minimum-password-length-range": "Minimum password length should be in a range from 5 to 50", + "minimum-uppercase-letters": "Minimum number of uppercase letters", + "minimum-uppercase-letters-range": "Minimum number of uppercase letters can't be negative", + "minimum-lowercase-letters": "Minimum number of lowercase letters", + "minimum-lowercase-letters-range": "Minimum number of lowercase letters can't be negative", + "minimum-digits": "Minimum number of digits", + "minimum-digits-range": "Minimum number of digits can't be negative", + "minimum-special-characters": "Minimum number of special characters", + "minimum-special-characters-range": "Minimum number of special characters can't be negative", + "password-expiration-period-days": "Password expiration period in days", + "password-expiration-period-days-range": "Password expiration period in days can't be negative", + "password-reuse-frequency-days": "Password reuse frequency in days", + "password-reuse-frequency-days-range": "Password reuse frequency in days can't be negative", + "allow-whitespace": "Allow whitespace", + "general-policy": "General policy", + "max-failed-login-attempts": "Maximum number of failed login attempts, before account is locked", + "minimum-max-failed-login-attempts-range": "Maximum number of failed login attempts can't be negative", + "user-lockout-notification-email": "In case user account lockout, send notification to email", + "domain-name": "Domain name", + "domain-name-unique": "Domain name and protocol need to unique.", + "domain-name-max-length": "Domain name should be less than 256", + "error-verification-url": "A domain name shouldn't contain symbols '/' and ':'. Example: thingsboard.io", + "connection-settings": "Connection settings", + "oauth2": { + "access-token-uri": "Access token URI", + "access-token-uri-required": "Access token URI is required.", + "activate-user": "Activate user", + "add-domain": "Add domain", + "delete-domain": "Delete domain", + "add-provider": "Add provider", + "delete-provider": "Delete provider", + "allow-user-creation": "Allow user creation", + "always-fullscreen": "Always fullscreen", + "authorization-uri": "Authorization URI", + "authorization-uri-required": "Authorization URI is required.", + "client-authentication-method": "Client authentication method", + "client-id": "Client ID", + "client-id-required": "Client ID is required.", + "client-id-max-length": "Client ID should be less than 256", + "client-secret": "Client secret", + "client-secret-required": "Client secret is required.", + "client-secret-max-length": "Client secret should be less than 2049", + "custom-setting": "Custom settings", + "customer-name-pattern": "Customer name pattern", + "customer-name-pattern-max-length": "Customer name pattern should be less than 256", + "parent-customer-name-pattern": "Parent customer name pattern", + "user-groups-name-pattern": "User groups name pattern", + "default-dashboard-name": "Default dashboard name", + "default-dashboard-name-max-length": "Default dashboard name should be less than 256", + "delete-domain-text": "Be careful, after the confirmation a domain and all provider data will be unavailable.", + "delete-domain-title": "Are you sure you want to delete settings the domain '{{domainName}}'?", + "delete-registration-text": "Be careful, after the confirmation a provider data will be unavailable.", + "delete-registration-title": "Are you sure you want to delete the provider '{{name}}'?", + "email-attribute-key": "Email attribute key", + "email-attribute-key-required": "Email attribute key is required.", + "email-attribute-key-max-length": "Email attribute key should be less than 32", + "first-name-attribute-key": "First name attribute key", + "first-name-attribute-key-max-length": "First name attribute key should be less than 32", + "general": "General", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "Last name attribute key", + "last-name-attribute-key-max-length": "Last name attribute key should be less than 32", + "login-button-icon": "Login button icon", + "login-button-label": "Provider label", + "login-button-label-placeholder": "Login with $(Provider label)", + "login-button-label-required": "Label is required.", + "login-provider": "Login provider", + "mapper": "Mapper", + "new-domain": "New domain", + "oauth2": "OAuth2", + "password-max-length": "Password should be less than 256", + "redirect-uri-template": "Redirect URI template", + "copy-redirect-uri": "Copy redirect URI", + "registration-id": "Registration ID", + "registration-id-required": "Registration ID is required.", + "registration-id-unique": "Registration ID need to unique for the system.", + "scope": "Scope", + "scope-required": "Scope is required.", + "tenant-name-pattern": "Tenant name pattern", + "tenant-name-pattern-required": "Tenant name pattern is required.", + "tenant-name-pattern-max-length": "Tenant name pattern ishould be less than 256", + "tenant-name-strategy": "Tenant name strategy", + "type": "Mapper type", + "uri-pattern-error": "Invalid URI format.", + "url": "URL", + "url-pattern": "Invalid URL format.", + "url-required": "URL is required.", + "url-max-length": "URL should be less than 256", + "user-info-uri": "User info URI", + "user-info-uri-required": "User info URI is required.", + "username-max-length": "User name should be less than 256", + "user-name-attribute-name": "User name attribute key", + "user-name-attribute-name-required": "User name attribute key is required", + "protocol": "Protocol", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Enable OAuth2 settings", + "domains": "Domains", + "mobile-apps": "Mobile applications", + "no-mobile-apps": "No applications configured", + "mobile-package": "Application package", + "mobile-package-placeholder": "Ex.: my.example.app", + "mobile-package-hint": "For Android: your own unique Application ID. For iOS: Product bundle identifier.", + "mobile-package-unique": "Application package must be unique.", + "mobile-app-secret": "Application secret", + "invalid-mobile-app-secret": "Application secret must contain only alphanumeric characters and must be between 16 and 2048 characters long.", + "copy-mobile-app-secret": "Copy application secret", + "add-mobile-app": "Add application", + "delete-mobile-app": "Delete application info", + "providers": "Providers", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "All platforms", + "smtp-provider": "SMTP provider", + "allowed-platforms": "Allowed platforms", + "authentication": "Authentication", + "basic": "Basic", + "provider": "Provider", + "redirect-url": "Redirect URI", + "domain-name": "Domain name", + "redirect-url-template": "Redirect URI template", + "microsoft-tenant-id": "Directory (tenant) Id", + "microsoft-tenant-id-required": "Directory (tenant) Id is required", + "token-uri": "Token URI", + "token-uri-required": "Token URI is required", + "redirect-uri": "Redirect URI", + "google-provider": "Google", + "microsoft-provider": "Office 365", + "sendgrid-provider": "Sendgrid", + "custom-provider": "Custom", + "generate-access-token": "Generate access token", + "update-access-token": "Update access token", + "access-token-status": "Access token status:", + "token-status-generated": "generated", + "token-status-not-generated": "not generated" + }, + "smpp-provider": { + "smpp-version": "SMPP version", + "smpp-host": "SMPP host", + "smpp-host-required": "SMPP host is required", + "smpp-port": "SMPP port", + "smpp-port-required": "SMPP port is required", + "system-id": "System ID", + "system-id-required": "System ID is required", + "password": "Password", + "password-required": "Password is required", + "type-settings": "Type settings", + "source-settings": "Source settings", + "destination-settings": "Destination settings", + "additional-settings": "Additional settings", + "system-type": "System type", + "bind-type": "Bind type", + "service-type": "Service type", + "source-address": "Source address", + "source-ton": "Source TON", + "source-npi": "Source NPI", + "destination-ton": "Destination TON (Type of Number)", + "destination-npi": "Destination NPI (Numbering Plan Identification)", + "address-range": "Address range", + "coding-scheme": "Coding scheme", + "bind-type-tx": "Transmitter", + "bind-type-rx": "Receiver", + "bind-type-trx": "Transciever", + "ton-unknown": "Unknown", + "ton-international": "International", + "ton-national": "National", + "ton-network-specific": "Network Specific", + "ton-subscriber-number": "Subscriber Number", + "ton-alphanumeric": "Alphanumeric", + "ton-abbreviated": "Abbreviated", + "npi-unknown": "0 - Unknown", + "npi-isdn": "1 - ISDN/telephone numbering plan (E163/E164)", + "npi-data-numbering-plan": "3 - Data numbering plan (X.121)", + "npi-telex-numbering-plan": "4 - Telex numbering plan (F.69)", + "npi-land-mobile": "6 - Land Mobile (E.212)", + "npi-national-numbering-plan": "8 - National numbering plan", + "npi-private-numbering-plan": "9 - Private numbering plan", + "npi-ermes-numbering-plan": "10 - ERMES numbering plan (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Internet (IP)", + "npi-wap-client-id": "18 - WAP Client Id (to be defined by WAP Forum)", + "scheme-smsc": "0 - SMSC Default Alphabet (ASCII for short and long code and to GSM for toll-free)", + "scheme-ia5": "1 - IA5 (ASCII for short and long code, Latin 9 for toll-free (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - Octet Unspecified (8-bit binary)", + "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Octet Unspecified (8-bit binary)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Cyrillic (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Latin/Hebrew (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Pictogram Encoding", + "scheme-music-codes": "10 - Music Codes (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Extended Kanji JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Korean Graphic Character Set (KS C 5601/KS X 1001)" + }, + "queue-select-name": "Select queue name", + "queue-name": "Name", + "queue-name-required": "Queue name is required!", + "queues": "Queues", + "queue-partitions": "Partitions", + "queue-submit-strategy": "Submit strategy", + "queue-processing-strategy": "Processing strategy", + "queue-configuration": "Queue configuration", + "repository-settings": "Repository settings", + "repository": "Repository", + "repository-url": "Repository URL", + "repository-url-required": "Repository URL is required.", + "default-branch": "Default branch name", + "repository-read-only": "Read-only", + "show-merge-commits": "Show merge commits", + "authentication-settings": "Authentication settings", + "auth-method": "Authentication method", + "auth-method-username-password": "Password / access token", + "auth-method-username-password-hint": "GitHub users must use access tokens with write permissions to the repository.", + "auth-method-private-key": "Private key", + "password-access-token": "Password / access token", + "change-password-access-token": "Change password / access token", + "private-key": "Private key", + "drop-private-key-file-or": "Drag and drop a private key file or", + "passphrase": "Passphrase", + "enter-passphrase": "Enter passphrase", + "change-passphrase": "Change passphrase", + "check-access": "Check access", + "check-repository-access-success": "Repository access successfully verified!", + "delete-repository-settings-title": "Are you sure you want to delete repository settings?", + "delete-repository-settings-text": "Be careful, after the confirmation the repository settings will be removed and version control feature will be unavailable.", + "auto-commit-settings": "Auto-commit settings", + "auto-commit": "Auto-commit", + "auto-commit-entities": "Auto-commit entities", + "no-auto-commit-entities-prompt": "No entities configured for auto-commit", + "delete-auto-commit-settings-title": "Are you sure you want to delete auto-commit settings?", + "delete-auto-commit-settings-text": "Be careful, after the confirmation the auto-commit settings will be removed and auto-commit will be disabled for all entities.", + "2fa": { + "2fa": "Two-factor authentication", + "available-providers": "Available providers", + "issuer-name": "Issuer name", + "issuer-name-required": "Issuer name is required.", + "max-verification-failures-before-user-lockout": "Max verification failures before user lockout", + "max-verification-failures-before-user-lockout-pattern": "Max verification failures must be a positive integer.", + "number-of-checking-attempts": "Number of checking attempts", + "number-of-checking-attempts-pattern": "Number of checking attempts must be a positive integer.", + "number-of-checking-attempts-required": "Number of checking attempts is required.", + "number-of-codes": "Number of codes", + "number-of-codes-pattern": "Number of codes must be a positive integer.", + "number-of-codes-required": "Number of codes is required.", + "provider": "Provider", + "retry-verification-code-period": "Retry verification code period (sec)", + "retry-verification-code-period-pattern": "Minimal period time is 5 sec", + "retry-verification-code-period-required": "Retry verification code period is required.", + "total-allowed-time-for-verification": "Total allowed time for verification (sec)", + "total-allowed-time-for-verification-pattern": "Minimal total allowed time is 60 sec", + "total-allowed-time-for-verification-required": "Total allowed time is required.", + "use-system-two-factor-auth-settings": "Use system two factor auth settings", + "verification-code-check-rate-limit": "Verification code check rate limit", + "verification-code-lifetime": "Verification code lifetime (sec)", + "verification-code-lifetime-pattern": "Verification code lifetime must be a positive integer.", + "verification-code-lifetime-required": "Verification code lifetime is required.", + "verification-message-template": "Verification message template", + "verification-limitations": "Verification limitations", + "verification-message-template-pattern": "Verification message need to contains pattern: ${code}", + "verification-message-template-required": "Verification message template is required.", + "within-time": "Within time (sec)", + "within-time-pattern": "Time must be a positive integer.", + "within-time-required": "Time is required." + }, + "jwt": { + "security-settings": "JWT security settings", + "issuer-name": "Issuer name", + "issuer-name-required": "Issuer name is required.", + "signings-key": "Signing key", + "signings-key-hint": "Base64 encoded string representing at least 256 bits of data.", + "signings-key-required": "Signing key is required.", + "signings-key-min-length": "Signing key must be at least 256 bits of data.", + "signings-key-base64": "Signing key must be base64 format.", + "expiration-time": "Token expiration time (sec)", + "expiration-time-required": "Token expiration time is required.", + "expiration-time-pattern": "Token expiration time be a positive integer.", + "expiration-time-min": "Minimum time is 60 seconds (1 minute).", + "refresh-expiration-time": "Refresh token expiration time (sec)", + "refresh-expiration-time-required": "Refresh token expiration time is required.", + "refresh-expiration-time-pattern": "Refresh token expiration time be a positive integer.", + "refresh-expiration-time-min": "Minimum time is 900 seconds (15 minute).", + "refresh-expiration-time-less-token": "Refresh token time must be greater token time.", + "generate-key": "Generate key", + "info-header": "All users will be to re-logined", + "info-message": "Change of the JWT Signing Key will cause all issued tokens to be invalid. All users will need to re-login. This will also affect scripts that use Rest API/Websockets." + }, + "resources": "Resources", + "notifications": "Notifications", + "notifications-settings": "Notifications settings", + "slack-api-token": "Slack API token", + "slack": "Slack", + "slack-settings": "Slack settings" + }, + "alarm": { + "alarm": "Įspėjimas", + "alarms": "Įspėjimai", + "all-alarms": "Visi Įspėjimai", + "select-alarm": "Pasirinkti įspėjimą", + "no-alarms-matching": "Su '{{entity}}' susijusių įspėjimų nėra.", + "alarm-required": "Įspėjimas yra privalomas", + "alarm-filter": "Įspėjimų filtras", + "filter": "Filtras", + "alarm-status": "Įspėjimo statusas", + "alarm-status-list": "Įspėjimo statusų sąrašas", + "any-status": "Visi statusai", + "search-status": { + "ANY": "Visi", + "ACTIVE": "Aktyvūs", + "CLEARED": "Atmesti", + "ACK": "Pripažinti", + "UNACK": "Nepatvirtinti" + }, + "display-status": { + "ACTIVE_UNACK": "Aktyvūs nepatvirtinti", + "ACTIVE_ACK": "Aktyvūs pripažinti", + "CLEARED_UNACK": "Atmesti nepatvirtinti", + "CLEARED_ACK": "Atmesti pripažinti" + }, + "no-alarms-prompt": "Įspėjimų nėra", + "created-time": "Sukūrimo laikas", + "type": "Tipas", + "severity": "Lygis", + "originator": "Iniciatorius", + "originator-type": "Iniciatoriaus tipas", + "details": "Pastabos", + "originator-label": "Iniciatoriaus pavadinimas", + "assign": "Priskirti", + "assignments": "Priskyrimai", + "assignee": "Atsakingas", + "assignee-id": "Atsakingo asmens ID", + "assignee-first-name": "Atsakingo asmens vardas", + "assignee-last-name": "Atsakingo asmens pavardė", + "assignee-email": "Atsakingo asmens e-paštas", + "unassigned": "Panaikinti priskyrimą", + "assignee-not-set": "Visi", + "status": "Statusas", + "alarm-details": "Įspėjimo pastabos", + "start-time": "Pradžia", + "assign-time": "Priskyrimo laikas", + "end-time": "Pabaiga", + "ack-time": "Pripažinimo laikas", + "clear-time": "Atmetimo laikas", + "duration": "Trukmė", + "alarm-severity-list": "Įspėjimų lygiai", + "any-severity": "Visi lygiai", + "severity-critical": "Kritinis", + "severity-major": "Svarbus", + "severity-minor": "Antraeilis", + "severity-warning": "Įspėjimas", + "severity-indeterminate": "Nenustatytas", + "acknowledge": "Patvirtinti", + "clear": "Atmesti", + "delete": "Panaikinti", + "search": "Įspėjimo paieška", + "selected-alarms": "Pasirinkta { count, plural, =1 {1 įspėjimas} other {# įspėjimai} }", + "no-data": "Nėra duomenų", + "polling-interval": "Įspėjimų tikrinimo intervalas (sek)", + "polling-interval-required": "Reikalingas įspėjimų tikrinimo intervalas", + "min-polling-interval-message": "Mažiausias įspėjimų tikrinimo intervalas yra 1 sekundė.", + "aknowledge-alarms-title": "Pripažinti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }", + "aknowledge-alarms-text": "Pripažinti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }?", + "aknowledge-alarm-title": "Pripažinti įspėjimą", + "aknowledge-alarm-text": "Pripažinti įspėjimą?", + "selected-alarms-are-acknowledged": "Pasirinkti įspėjimai pripažinti", + "clear-alarms-title": "Atmesti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }", + "clear-alarms-text": "Atmesti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }?", + "clear-alarm-title": "Atmesti įspėjimą", + "clear-alarm-text": "Atmesti įspėjimą?", + "delete-alarms-title": "Panaikinti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }", + "delete-alarms-text": "Panaikinti { count, plural, =1 {1 įspėjimą} other {# įspėjimus} }?", + "selected-alarms-are-cleared": "Pasirinkti įspėjimai atmesti", + "alarm-status-filter": "Įspėjimų statusai", + "alarm-filter-title": "Įspėjimų filtras", + "assigned": "Priskirta", + "filter-title": "Filtras", + "max-count-load": "Didžiausias rodomų įspėjimų skaičius (0 – neribotas)", + "max-count-load-required": "Reikalingas didžiausias rodomų įspėjimų skaičius (0 – neribotas).", + "max-count-load-error-min": "Mažiausia reikšmė yra 0.", + "fetch-size": "Fetch size", + "fetch-size-required": "Fetch size is required.", + "fetch-size-error-min": "Mažiausia rekšmė yra 10.", + "alarm-type-list": "Įspėjimų tipai", + "any-type": "Visi tipai", + "assigned-to-current-user": "Priskirta prisijungusiam vartotojui", + "assigned-to-me": "Priskirta man", + "search-propagated-alarms": "Ieškoti išplatintų įspėjimų", + "comments": "Įspėjimo pastabos", + "show-more": "Rodyti daugiau", + "additional-info": "Papildoma informacija", + "alarm-type": "Įspėjimo tipas", + "enter-alarm-type": "Įveskite įspėjimo tipą", + "no-alarm-types-matching": "Nėra įspėjimų tipų, kurie atitiktų '{{entitySubtype}}'.", + "alarm-type-list-empty": "Nepasirinktas įspėjimų tipas" + }, + "alarm-activity": { + "add": "Pridėti komentarą...", + "alarm-comment": "Įspėjimo komentaras", + "comments": "Komentarai", + "delete-alarm-comment": "Ištrinti komentarą?", + "refresh": "Atnaujinti", + "oldest-first": "Seniausi viršuje", + "newest-first": "Naujausi viršuje", + "activity": "Veikla", + "export": "Eksportuoti į CSV", + "author": "Sukūrė", + "created-date": "Sukūrimo dara", + "edited-date": "Redagavimo data", + "text": "Tekstas", + "system": "Sistema" + }, + "alias": { + "add": "Pridėti pseudonimą", + "edit": "Redaguoti pseudonimą", + "name": "Pseudonimas", + "name-required": "Reikalingas pseudonimas", + "duplicate-alias": "Toks pseudonimas sistemoje jau yra.", + "filter-type-single-entity": "Vienas subjektas", + "filter-type-entity-group": "Subjektų grupė", + "filter-type-entity-list": "Subjektų sąrašas", + "filter-type-entity-name": "Subjekto pavadinimas", + "filter-type-entity-type": "Subjekto tipas", + "filter-type-entity-group-list": "Subjektų grupių sąrašas", + "filter-type-entity-group-name": "Subjektų grupės pavadinimas", + "filter-type-entities-by-group-name": "Subjektai pagal grupės pavadinimą", + "filter-type-state-entity": "Subjektas iš skydelio būsenos", + "filter-type-state-entity-description": "Subjektas iš skydelio būsenos parametrų", + "filter-type-state-entity-owner": "Skydelio būsenos subjekto savininkas", + "filter-type-state-entity-owner-description": "Subjekto, iš skydelio būsenos parametrų, savininkas", + "filter-type-asset-type": "Turto tipas", + "filter-type-asset-type-description": "Turtas, kurio tipas '{{assetTypes}}'", + "filter-type-asset-type-and-name-description": "Turtas, kurio tipas '{{assetTypes}}' ir kurio pavadinimas prasideda '{{prefix}}'", + "filter-type-device-type": "Įrenginio tipas", + "filter-type-device-type-description": "Įrenginiai, kurių tipas '{{deviceTypes}}'", + "filter-type-device-type-and-name-description": "Įrenginiai, kurių tipas '{{deviceTypes}}' ir kurių pavadinimai '{{prefix}}'", + "filter-type-entity-view-type": "Subjekto rodinio tipas", + "filter-type-entity-view-type-description": "Subjektų rodiniai, kurių tipas '{{entityViewTypes}}'", + "filter-type-entity-view-type-and-name-description": "Subjektų rodiniai, kurių tipas '{{entityViewTypes}}' ir kurių pavadinimai prasideda '{{prefix}}'", + "filter-type-edge-type": "Edge type", + "filter-type-edge-type-description": "Edges of type '{{edgeTypes}}'", + "filter-type-edge-type-and-name-description": "Edges of type '{{edgeTypes}}' and with name starting with '{{prefix}}'", + "filter-type-relations-query": "Ryšių užklausa", + "filter-type-relations-query-description": "{{entities}} kurie turi {{relationType}} ryšį {{direction}} {{rootEntity}}", + "filter-type-edge-search-query": "Edge search query", + "filter-type-edge-search-query-description": "Edges with types {{edgeTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-scheduler-event": "Scheduler events", + "filter-type-scheduler-event-type-description": "Scheduler events with type '{{eventType}}'", + "filter-type-scheduler-event-originator-description": "Scheduler events of specific originator", + "filter-type-scheduler-event-type-originator-description": "Scheduler events of specific originator with '{{eventType}}'", + "filter-type-asset-search-query": "Turto paieškos užklausa", + "filter-type-asset-search-query-description": "{{assetTypes}} tipo turtas, turintis {{relationType}} tipo ryšį {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Įrenginio paieškos užklausa", + "filter-type-device-search-query-description": "{{deviceTypes}} tipo įrenginiai, turintys {{relationType}} tipo ryšį {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Subjektų rodinio paieškos užklausa", + "filter-type-entity-view-search-query-description": "{{entityViewTypes}} tipo subjektai, turintys {{relationType}} tipo ryšį {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "Api Usage State", + "entity-filter": "Subjektų filtras", + "resolve-multiple": "Traktuoti kaip kelis subjektus", + "filter-type": "Filtro tipas", + "filter-type-required": "Filtro tipas būtinas.", + "entity-filter-no-entity-matched": "Subjektų, atitinkančių filtro kriterijus, nerasta.", + "no-entity-filter-specified": "Subjektų filtras nenustatytas", + "root-state-entity": "Subjektą, iš skydelio būsenos, naudoti kaip pagrindinį", + "group-state-entity": "Skydelio būsenos subjektą naudoti kaip subjektų grupę", + "group-state-entity-owner": "Skydelio būsenos subjektą naudoti kaip subjektų grupės sąvininką", + "last-level-relation": "Gauti tik paskutinio lygio ryšį", + "root-entity": "Pagridinis subjektas", + "state-entity-parameter-name": "Būsenos subjekto parametro pavadinimas", + "default-state-entity": "Numatytasis būsenos subjektas", + "default-state-entity-group": "Numatytoji būsenos subjektų grupė", + "default-entity-parameter-name": "Pagal nutylėjimą", + "max-relation-level": "Maksimalus ryšių lygis", + "unlimited-level": "Neribojamas lygis", + "state-entity": "Skydelio būsenos subjektas", + "entities-of-group-state-entity": "Subjektai iš skydelio būsenos subjektų grupės", + "all-entities": "Visi subjektai", + "any-relation": "visi", + "originator": "Iniciatorius", + "originator-state-entity": "Skydelio būsenos subjektą naudoti kaip iniciatorių", + "scheduler-event-type": "Scheduler event type" + }, + "asset": { + "all": "Visas", + "all-assets": "Visas turtas", + "groups": "Grupės", + "shared": "Bendrinamas", + "asset": "Turtas", + "assets": "Turtas", + "management": "Turto valdymas", + "view-assets": "Peržiūrėti turtą", + "add": "Pridėti turtą", + "asset-type-max-length": "Turto tipas turi būti trumpesnis nei 256 simboliai", + "assign-to-customer": "Priskirti klientui", + "assign-asset-to-customer": "Turtą priskirti klientui", + "assign-asset-to-customer-text": "Pasirinkite turtą, kurį norite priskiri klientui", + "no-assets-text": "Turto nerasta", + "assign-to-customer-text": "Pasirinkite klientą, kuriam priskiriamas turtas", + "public": "Viešas", + "assignedToCustomer": "Priskirti klientui", + "make-public": "Neriboti turto matomumo", + "make-private": "Riboti turto matomumą", + "unassign-from-customer": "Atsieti nuo kliento", + "delete": "Panaikinti turtą", + "asset-public": "Turtas yra viešas", + "asset-type": "Turto tipas", + "asset-type-required": "Turto tipas būtinas.", + "select-asset-type": "Pasirinkite turto tipą", + "enter-asset-type": "Įveskite turto tipą", + "any-asset": "Visas turtas", + "no-asset-types-matching": "Turto tipas, atitinkantis '{{entitySubtype}}', nerastas.", + "asset-type-list-empty": "Nepasirinkti turto tipai.", + "asset-types": "Turto tipai", + "name": "Pavadinimas", + "name-required": "Pavadinimas yra privalomas.", + "name-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai", + "label-max-length": "Vardas turi būti trumpesnis nei 256 simboliai", + "description": "Aprašymas", + "type": "Tipas", + "type-required": "Tipas yra prvalomas.", + "details": "Pastabos", + "events": "Įvykiai", + "add-asset-text": "Pridėti turtą", + "asset-details": "Pastabos apie turtą", + "assign-assets": "Priskirti turtą", + "assign-assets-text": "Priskirti { count, plural, =1 {1 turtą} other {# turtus} } to customer", + "assign-asset-to-edge-title": "Assign Asset(s) To Edge", + "assign-asset-to-edge-text": "Please select the assets to assign to the edge", + "delete-assets": "Pašalinti turtą", + "unassign-assets": "Atsieti turtą", + "unassign-assets-action-title": "Atsieti { count, plural, =1 {1 turtą} other {# turtus} } nuo kliento?", + "assign-new-asset": "Prisirti naują turtą", + "delete-asset-title": "Pašalinti '{{assetName}}' turtą?", + "delete-asset-text": "Būkite dėmesingi, po patvirtinimo, turtas ir su juo susijusi informacija, bus pašalinta.", + "delete-assets-title": "Pašalinti { count, plural, =1 {1 turtą} other {# turtus} }?", + "delete-assets-action-title": "Pašalinti { count, plural, =1 {1 turtą} other {# turtus} }", + "delete-assets-text": "Būkite dėmesingi, po patvirtinimo, visi turto įrašai ir su jais susijusi informacija, bus pašalinta.", + "make-public-asset-title": "Ar tikrai norite neriboti turto '{{assetName}}' matomumo?", + "make-public-asset-text": "Po patvirtinimo turtas ir visa su juo susijusi informacija bus prieinama visiems.", + "make-private-asset-title": "Ar tikrai norite riboti turto '{{assetName}}' matomumą?", + "make-private-asset-text": "Po patvirtinimo, turtas ir visa su juo susijusi informacija nebus prieinama kitiems.", + "unassign-asset-title": "Ar tikrai norite atsieti turtą '{{assetName}}'?", + "unassign-asset-text": "Po patvirtinimo turtas bus atsietas ir klientas neteks prieigos prie jo.", + "unassign-asset": "Atsieti turtą", + "unassign-assets-title": "Ar tikrai norite atsieti { count, plural, =1 {1 turtą} other {# turtus} }?", + "unassign-assets-text": "Po patvirtinimo visas turtas bus atsietas ir klientas neteks prieigos prie jo.", + "copyId": "Kopijuoti turto Id", + "idCopiedMessage": "Turto Id nukopijuotas į iškarpinę", + "select-asset": "Pasirinkti turtą", + "no-assets-matching": "Turtas atitinkantis '{{entity}}' nerastas.", + "asset-required": "Turtas būtinas", + "name-starts-with": "Turto pavadinimas prasideda", + "help-text": "naudokite simbolį '%' pagal tai, kaip norite ieškoti: '%turto_pavadinimo_fragmentas%', '%turto_pavadinimo_pabaiga', 'turto_pavadinimo_pradžia%'.", + "search": "Turto paieška", + "select-group-to-add": "Pasirinkti grupę, į kurią turtą pridėti", + "select-group-to-move": "Pasirinkite grupę, į kurią turtą perkelti", + "remove-assets-from-group": "Ar tikrai norite { count, plural, =1 {1 turtą} other {# turtus} } pašalinti iš grupės '{{entityGroup}}'?", + "group": "Turto grupė", + "list-of-groups": "{ count, plural, =1 {Viena turto grupė} other { # Turto grupių sąrašas} }", + "group-name-starts-with": "Turto grupės, kurių pavadinimai prasideda '{{prefix}}'", + "import": "Importuoti turtą", + "asset-file": "Turto failas", + "label": "Etiketė", + "assign-asset-to-edge": "Assign Asset(s) To Edge", + "unassign-asset-from-edge": "Unassign asset", + "unassign-asset-from-edge-title": "Are you sure you want to unassign the asset '{{assetName}}'?", + "unassign-asset-from-edge-text": "After the confirmation the asset will be unassigned and won't be accessible by the edge.", + "unassign-assets-from-edge-title": "Are you sure you want to unassign { count, plural, =1 {1 asset} other {# assets} }?", + "unassign-assets-from-edge-text": "After the confirmation all selected assets will be unassigned and won't be accessible by the edge.", + "selected-assets": "Pasirinkta { count, plural, =1 {1 turtas} other {# turtai} }" + }, + "attribute": { + "attributes": "Atributai", + "latest-telemetry": "Paskutinė telemetrija", + "no-latest-telemetry": "Telemetrijos nėra", + "attributes-scope": "Subjekto atributų kontekstas", + "scope-telemetry": "Telemetrija", + "scope-latest-telemetry": "Paskutinė telemetrija", + "scope-client": "Kliento atributai", + "scope-server": "Serverio atributai", + "scope-shared": "Bendrinami atributai", + "add": "Pridėti atributą", + "add-attribute-prompt": "Pridėkite atributą", + "key": "Raktas", + "key-max-length": "Raktas negali viršyti 256 simbolių", + "last-update-time": "Atnaujinimo laikas", + "key-required": "Atributo raktas būtinas.", + "value": "Reikšmė", + "value-required": "Atributo reikšmė būtina.", + "telemetry-key-required": "Telemetrijos raktas būtinas", + "telemetry-value-required": "Telemetrijos reikšmė būtina", + "delete-attributes-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 atributą} other {# atributus} }?", + "delete-attributes-text": "Būkite dėmesingi, po patvirtinimo pasirinkti atributai bus pašalinti.", + "delete-attributes": "Pašalinti atributus", + "enter-attribute-value": "Įveskite atributo reikšmę", + "show-on-widget": "Rodyti valdiklyje", + "widget-mode": "Valdiklio režimas", + "next-widget": "Sekantis valdiklis", + "prev-widget": "Ankstesnis valdiklis", + "add-to-dashboard": "Pridėti į skydelį", + "add-widget-to-dashboard": "Pridėti valdiklį į skydelį", + "selected-attributes": "Pasirinkta { count, plural, =1 {1 atributas} other {# atributai} }", + "selected-telemetry": "Pasirinkta { count, plural, =1 {1 telemetrijos blokas} other {# telemetrijos blokai} }", + "no-attributes-text": "Atributų nėra", + "no-telemetry-text": "Telemetrijos nėra", + "copy-key": "Kopijuoti raktą", + "add-telemetry": "Pridėti telemetriją", + "copy-value": "Kopijuoti reikšmę", + "delete-timeseries": { + "start-time": "Pradžia", + "ends-on": "Pabaiga", + "strategy": "Strategija", + "delete-strategy": "Šalinimo startegija", + "all-data": "Pašalinti visus duomenis", + "all-data-except-latest-value": "Pašalinti visus duomenis išskyrus naujausią reikšmę", + "latest-value": "Pašalinti naujausią reikšmę", + "all-data-for-time-period": "Pašalinti visus duomenis periodui", + "rewrite-latest-value": "Perrašyti naujausią reikšmę" + } + }, + "api-usage": { + "api-features": "API features", + "api-usage": "Api Usage", + "alarm": "Alarm", + "alarms-created": "Alarms created", + "alarms-created-daily-activity": "Alarms created daily activity", + "alarms-created-hourly-activity": "Alarms created hourly activity", + "alarms-created-monthly-activity": "Alarms created monthly activity", + "data-points": "Data points", + "data-points-storage-days": "Data points storage days", + "device-api": "Device API", + "email": "Email", + "email-messages": "Email messages", + "email-messages-daily-activity": "Email messages daily activity", + "email-messages-monthly-activity": "Email messages monthly activity", + "exceptions": "Exceptions", + "executions": "Executions", + "javascript": "JavaScript", + "javascript-executions": "JavaScript executions", + "javascript-functions": "JavaScript functions", + "javascript-functions-daily-activity": "JavaScript functions daily activity", + "javascript-functions-hourly-activity": "JavaScript functions hourly activity", + "javascript-functions-monthly-activity": "JavaScript functions monthly activity", + "latest-error": "Latest Error", + "messages": "Messages", + "notifications": "Notifications", + "notifications-email-sms": "Notifications (Email/SMS)", + "notifications-hourly-activity": "Notifications hourly activity", + "permanent-failures": "${entityName} Permanent Failures", + "permanent-timeouts": "${entityName} Permanent Timeouts", + "processing-failures": "${entityName} Processing Failures", + "processing-failures-and-timeouts": "Processing Failures and Timeouts", + "processing-timeouts": "${entityName} Processing Timeouts", + "queue-stats": "Queue Stats", + "rule-chain": "Rule Chain", + "rule-engine": "Rule Engine", + "rule-engine-daily-activity": "Rule Engine daily activity", + "rule-engine-executions": "Rule Engine executions", + "rule-engine-hourly-activity": "Rule Engine hourly activity", + "rule-engine-monthly-activity": "Rule Engine monthly activity", + "rule-engine-statistics": "Rule Engine Statistics", + "rule-node": "Rule Node", + "sms": "SMS", + "sms-messages": "SMS messages", + "sms-messages-daily-activity": "SMS messages daily activity", + "sms-messages-monthly-activity": "SMS messages monthly activity", + "successful": "${entityName} Successful", + "telemetry": "Telemetry", + "telemetry-persistence": "Telemetry persistence", + "telemetry-persistence-daily-activity": "Telemetry persistence daily activity", + "telemetry-persistence-hourly-activity": "Telemetry persistence hourly activity", + "telemetry-persistence-monthly-activity": "Telemetry persistence monthly activity", + "transport": "Transport", + "transport-daily-activity": "Transport daily activity", + "transport-data-points": "Transport data points", + "transport-hourly-activity": "Transport hourly activity", + "transport-messages": "Transport messages", + "transport-monthly-activity": "Transport monthly activity", + "view-details": "View details", + "view-statistics": "View statistics" + }, + "api-limit": { + "cassandra-queries": "Cassandra queries", + "entity-version-creation": "Entity version creation", + "entity-version-load": "Entity version load", + "integration-messages": "Integration messages", + "integration-messages-per-device": "Integration messages per device", + "notification-requests": "Notification requests", + "notification-requests-per-rule": "Notification requests per rule", + "reports-generation": "Reports generation", + "rest-api-requests": "REST API requests", + "rest-api-requests-per-customer": "REST API requests per customer", + "transport-messages": "Transport messages", + "transport-messages-per-device": "Transport messages per device", + "ws-updates-per-session": "WS updates per session" + }, + "audit-log": { + "audit": "Auditas", + "audit-logs": "Audito žurnalas", + "timestamp": "Laiko žyma", + "entity-type": "Subjektas", + "entity-name": "Pavadinimas", + "user": "Vartotojas", + "type": "Veiksmas", + "status": "Statusas", + "details": "Informacija", + "type-added": "Pridėta", + "type-deleted": "Pašalinta", + "type-updated": "Atnaujinta", + "type-attributes-updated": "Attributai atnaujinti", + "type-attributes-deleted": "Atributai pašalinti", + "type-rpc-call": "RPC iškvietimas", + "type-credentials-updated": "Įgaliojimai atnaujinti", + "type-assigned-to-customer": "Priskirta klientui", + "type-unassigned-from-customer": "Atsieta nuo kliento", + "type-assigned-to-edge": "Assigned to Edge", + "type-unassigned-from-edge": "Unassigned from Edge", + "type-activated": "Aktyvuota", + "type-suspended": "Sustabdyta", + "type-credentials-read": "Įgaliojimų skaitymas", + "type-attributes-read": "Atributų skaitymas", + "type-added-to-entity-group": "Įtraukta į grupę", + "type-removed-from-entity-group": "Pašalinta iš grupės", + "type-relation-add-or-update": "ryšys atnaujintas", + "type-relation-delete": "Ryšys panaikintas", + "type-relations-delete": "Visi ryšiai panaikinti", + "type-alarm-ack": "Pripažinta", + "type-alarm-clear": "Išvalyta", + "type-alarm-assign": "Priskitas", + "type-alarm-unassign": "Atsietas", + "type-added-comment": "Pridėtas komentaras", + "type-updated-comment": "Atnaujintas komentaras", + "type-deleted-comment": "Pašalintas komentaras", + "type-rest-api-rule-engine-call": "Rule engine REST API call", + "type-made-public": "Padaryti viešu", + "type-made-private": "Padaryti privačiu", + "type-login": "Prisijungti", + "type-logout": "Atsijungti", + "type-lockout": "Užblokuoti", + "status-success": "Sėkmė", + "status-failure": "Nesėkmė", + "audit-log-details": "Informacija apie autido žurnalą", + "no-audit-logs-prompt": "Žurnalo įrašų nėra", + "action-data": "Informacija apie veiksmus", + "failure-details": "Informacija apie nesėkmę", + "search": "Žurnalo įrašų paieška", + "clear-search": "Išvalyti paiešką", + "type-assigned-from-tenant": "Priskirta valdytojo", + "type-assigned-to-tenant": "Priskirta valdytojui", + "type-provision-success": "Device provisioned", + "type-provision-failure": "Device provisioning was failed", + "type-timeseries-updated": "Telemetrija atnaujinta", + "type-timeseries-deleted": "Telemetrija pašalinta", + "type-owner-changed": "Savininkas pakeistas", + "type-sms-sent": "SMS sent" + }, + "confirm-on-exit": { + "message": "Turite neišsaugotų pakeitimų. Ar tikrai norite palikti šį puslapį?", + "html-message": "Turite neišsaugotų pakeitimų.
Ar tikrai norite palikti šį puslapį?", + "title": "Neišsaugoti pakeitimai" + }, + "contact": { + "country": "Šalis", + "city": "Miestas", + "state": "Rajonas", + "postal-code": "Pašto kodas", + "postal-code-invalid": "Neteisingas pašto kodo formatas.", + "address": "Adresas", + "address2": "Adresas 2", + "phone": "Telefonas", + "email": "El. paštas", + "no-address": "Adresas nenurodytas", + "state-max-length": "Rajono pavadinimas negali viršyti 256 simbolių", + "phone-max-length": "Telefono numeris negali viršyti 256 simbolių", + "city-max-length": "Miesto pavadinimas negali viršyti 256 simbolių" + }, + "common": { + "username": "Vartoto vardas", + "password": "Slaptažodis", + "enter-username": "Įveskite vartotojo vardą", + "enter-password": "Įveskite slaptažodį", + "enter-search": "Įveskite paieškos kriterijus", + "created-time": "Sukūrimo laikas", + "loading": "Įkeliama...", + "proceed": "Tęsti", + "open-details-page": "Informacija", + "not-found": "Nerasta", + "documentation": "Dokumentacija" + }, + "converter": { + "converter": "Data converter", + "converters": "Data converters", + "select-converter": "Select data converter", + "no-converters-matching": "No data converters matching '{{entity}}' were found.", + "no-converters-found": "No data converters found.", + "converter-required": "Data converter is required", + "delete": "Delete converter", + "management": "Data converters management", + "add-converter-text": "Add new data converter", + "no-converters-text": "No data converters found", + "selected-converters": "{ count, plural, =1 {1 data converter} other {# data converters} } selected", + "delete-converter-title": "Are you sure you want to delete the data converter '{{converterName}}'?", + "delete-converter-text": "Be careful, after the confirmation the data converter and all related data will become unrecoverable.", + "delete-converters-title": "Are you sure you want to delete { count, plural, =1 {1 data converter} other {# data converters} }?", + "delete-converters-action-title": "Delete { count, plural, =1 {1 data converter} other {# data converters} }", + "delete-converters-text": "Be careful, after the confirmation all selected data converters will be removed and all related data will become unrecoverable.", + "events": "Events", + "add": "Add Data Converter", + "search": "Search data converters", + "converter-details": "Data converter details", + "details": "Details", + "copyId": "Copy converter Id", + "idCopiedMessage": "Converter Id has been copied to clipboard", + "debug-mode": "Debug mode", + "created-time": "Created time", + "name": "Name", + "name-required": "Name is required.", + "name-max-length": "Name should be less than 256", + "description": "Description", + "decoder": "Decoder", + "encoder": "Encoder", + "test-decoder-fuction": "Test decoder function", + "test-encoder-fuction": "Test encoder function", + "test-with-this-message": "{{test}} with this message", + "decoder-input-params": "Decoder input parameters", + "encoder-input-params": "Encoder input parameters", + "payload": "Payload", + "payload-content-type": "Payload content type", + "payload-content": "Payload content", + "message": "Message", + "message-type": "Message type", + "message-type-required": "Message type is required", + "test": "Test", + "metadata": "Metadata", + "metadata-required": "Metadata entries can't be empty.", + "integration-metadata": "Integration metadata", + "integration-metadata-required": "Integration metadata entries can't be empty.", + "output": "Output", + "import": "Import converter", + "export": "Export converter", + "export-failed-error": "Unable to export converter: {{error}}", + "create-new-converter": "Create new converter", + "converter-file": "Converter file", + "invalid-converter-file-error": "Unable to import converter: Invalid converter data structure.", + "type": "Type", + "type-required": "Type is required.", + "type-uplink": "Uplink", + "type-downlink": "Downlink", + "update-only-keys-list": "Update only keys list", + "update-only-keys-list-hint": "The values associated with the provided keys will be saved to the database only if they are different from the corresponding values in the previous converted message. This functionality applies to both attributes and telemetry in the converter output.", + "add-key": "Add key" + }, + "content-type": { + "json": "Json", + "text": "Text", + "binary": "Binary (Base64)" + }, + "color": { + "color": "Color", + "color-picker": "Color picker", + "primary-colors": "Primary colors", + "accent-colors": "Accent colors", + "no-color-selected": "No color selected" + }, + "customer": { + "all": "Visi", + "all-customers": "Visi klientai", + "groups": "Grupės", + "shared": "Bendrinami", + "hierarchy": "Hierarchija", + "customer": "Klientas", + "customers": "Klientai", + "management": "Klientų valdymas", + "dashboard": "Klientų skydelis", + "dashboards": "Kliientų skydeliai", + "devices": "Kliento įrenginiai", + "entity-views": "Kliento subjektų rodiniai", + "assets": "Kliento turtas", + "public-dashboards": "Vieši skydeliai", + "public-devices": "Vieši įrenginiai", + "public-assets": "Viešas turtas", + "public-entity-views": "Vieši subjektų rodiniai", + "add": "Pridėti klientą", + "delete": "Pašalinti klientą", + "manage-customer-user-groups": "Kliento vartotojų grupės", + "manage-customer-groups": "Klientų grupės", + "manage-customer-device-groups": "Kliento įrenginių grupės", + "manage-customer-asset-groups": "Kliento turto grupės", + "manage-customer-entity-view-groups": "Kliento subjektų rodinių grupės", + "manage-customer-edge-groups": "Manage customer edge groups", + "manage-customer-dashboard-groups": "Kliento skydelių grupės", + "manage-customer-users": "Kliento vartotojai", + "manage-customers": "Klientai", + "manage-customer-devices": "Kliento įrenginiai", + "manage-customer-entity-views": "Kliento subjektų rodiniai", + "manage-customer-dashboards": "Kliento skydeliai", + "manage-public-devices": "Vieši įrenginiai", + "manage-public-dashboards": "Vieši skydeliai", + "manage-customer-assets": "Kliento turtas", + "manage-customer-edges": "Manage customer edges", + "manage-public-assets": "Viešas turtas", + "add-customer-text": "Pridėti naują klientą", + "no-customers-text": "Klientų nėra", + "customer-details": "Informacija apie klientą", + "delete-customer-title": "Ar tikrai norite pašalinti klientą '{{customerTitle}}'?", + "delete-customer-text": "Būkite dėmesingi, po patvirtinimo, kliento ir su juo susijusios informacijos atkurti nebegalėsite.", + "delete-customers-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 klientą} other {# klientus} }?", + "delete-customers-action-title": "Pašalinti { count, plural, =1 {1 klientą} other {# klientus} }", + "delete-customers-text": "Būkite dėmesingi, po patvirtinimo, klientų ir su jais susijusios informacijos atkurti nebegalėsite.", + "manage-user-groups": "Vartotjų grupės", + "manage-asset-groups": "turto grupės", + "manage-device-groups": "Įrenginių grupės", + "manage-dashboard-groups": "Skydelių grupės", + "manage-entity-view-groups": "Subjektų rodinių grupės", + "manage-edge-groups": "Manage edge groups", + "manage-users": "Vartotojai", + "manage-assets": "Turtas", + "manage-devices": "Įrenginiai", + "manage-dashboards": "Skydeliai", + "manage-entity-views": "Subjektų rodiniai", + "title": "Pavadinimas", + "title-required": "Pavadinimas būtinas.", + "title-max-length": "Pavadinimas negali viršyti 256 simbolių", + "description": "Aprašymas", + "details": "Informacija", + "events": "Įvykiai", + "copyId": "Kopijuoti kliento Id", + "idCopiedMessage": "Kliento Id nukopijuotas į iškarpinę", + "select-customer": "Pasirinkti klientą", + "no-customers-matching": "Klientų, atitinkančių '{{entity}}' nėra.", + "customer-required": "Klientas būtinas", + "select-group-to-add": "Pasirinkite grupę, į kurią pridėti pažymėtus klientus", + "select-group-to-move": "Pasirinkite grupę, į kurią perkelti pažymėtus klientus", + "remove-customers-from-group": "Ar tikrai { count, plural, =1 {1 klientą} other {# klientus} } norite pašalinti iš grupės '{{entityGroup}}'?", + "group": "Klientų grupė", + "list-of-groups": "{ count, plural, =1 {Viena kliento grupė} other {Sąrašas # klientų grupių} }", + "group-name-starts-with": "Klientų grupės, kurios prasideda '{{prefix}}'", + "select-default-customer": "Pasirinkite numatytąjį klientą", + "default-customer": "Numatytasis klientas", + "default-customer-required": "Numatytasis klientas būtinas, norint suderinti prietaisų skydelį valdytojo lygiu", + "allow-white-labeling": "Leisti tinkinti aplikaciją", + "search": "Klientų paieška", + "selected-customers": "Pasirinkta { count, plural, =1 {1 klientas} other {# kientai} }", + "edges": "Customer edge instances", + "manage-edges": "Manage edges" + }, + "customers-hierarchy": { + "customers-hierarchy": "Klientų hierarchija", + "open-nav-tree": "Atverti navigacijos medį", + "return-to-top-level": "Grįžti į aukščiausią lygį" + }, + "custom-menu": { + "custom-menu": "Vartotojo meniu", + "custom-menu-hint": "Aprašykite pasirinktinį meniu JSON formoje. Šiame JSON nurodomas vartotojo meniu elementų sąrašas." + }, + "custom-translation": { + "custom-translation": "Vartotojo vertimai", + "translation-map": "Vertimų žemėlapis", + "key": "Vertimo raktas", + "import": "Importuoti vertimą", + "export": "Eksportuoti vertimą", + "export-data": "Eksportuoti vertimo duomenis", + "import-data": "Importuoti vertimo duomenis", + "translation-file": "Vertimų failas", + "invalid-translation-file-error": "Nepavyksta importuoti vertimo failo: Neteisinga duomenų struktūra.", + "custom-translation-hint": "Aprašykite vartotojo vertimą JSON formatu žemiau. Šis JSON perrašys standartinį vertimą. Paspaudę 'Parsisiųsti lokalės failą', gausite standartinio vertimo failą, kuriame galite surasti reikalingas raktų-reikšmių poros ir jas pakeisti savo JSON.", + "download-locale-file": "Parsisųsti lokalės failą" + }, + "date": { + "last-update-n-ago": "Last update N ago", + "last-update-n-ago-text": "Last update {{ agoText }}", + "custom-date": "Custom date", + "format": "Format", + "preview": "Preview" + }, + "datetime": { + "date-from": "Data nuo", + "time-from": "Laikas nuo", + "date-to": "Data iki", + "time-to": "Laikas iki" + }, + "dashboard": { + "all": "Visi", + "all-dashboards": "Visi skydeliai", + "groups": "Grupės", + "shared": "Bendrinami", + "dashboard": "Skydelis", + "dashboards": "Skydeliai", + "management": "Skydelių valdymas", + "view-dashboards": "Paržiūrėti skydelius", + "add": "Pridėti skydelį", + "assign-dashboard-to-customer": "Skydelį (-ius) priskirti klientui", + "assign-dashboard-to-customer-text": "Pasirinkite skydelius, kuriuos norite priskirti klientui", + "assign-to-customer-text": "Pasirinkite klientą, kuriam priskirti skydelį (-ius)", + "assign-to-customer": "Priskirti kientui", + "unassign-from-customer": "Atsieti nuo kliento", + "make-public": "Skydelį padaryti viešą", + "make-private": "Skydelį padaryti privatų", + "manage-assigned-customers": "Valdyti priskirtus klientus", + "assigned-customers": "Priskirti klientai", + "assign-to-customers": "Skydelį (-ius) priskirti klientams", + "assign-to-customers-text": "Pasirinkite klientus, kuriems priskirti skydelį (-ius)", + "unassign-from-customers": "Skydelį (-ius) atsieti nuo klientų", + "unassign-from-customers-text": "Pasirinkite klientus, kuriems atsieti skydelį (-ius)", + "no-dashboards-text": "Skydelių nėra", + "no-widgets": "Valdikliai nesukonfigūruoti", + "add-widget": "Pridėti naują valdiklį", + "add-widget-button-text": "Pridėt valdiklį", + "title": "Pavadinimas", + "image": "Skydelio paveiksliukas", + "mobile-app-settings": "Mobiliosios aplikacijos nustatymai", + "mobile-order": "Skydelio eilės tvarka mobiliojoje aplikacijoje", + "mobile-hide": "Mobiliojoje aplikacijoje skydelį paslėpti", + "update-image": "Atnaujinti skydelio paveiksliuką", + "take-screenshot": "Padaryti ekrano nuotrauką", + "select-widget-title": "Pasirinkite valdiklį", + "select-widget-value": "{{title}}: pasirinkite valdiklį", + "select-widget-subtitle": "Galimų valdiklių sąrašas", + "delete": "Pašalinti skydelį", + "title-required": "Pavadinimas būtinas.", + "title-max-length": "Pavadinimas negali viršyti 256 simbolių", + "description": "Aprašymas", + "details": "Informacija", + "dashboard-details": "Informacija", + "add-dashboard-text": "Pridėti naują skydelį", + "assign-dashboards": "Priskirti skydelius", + "assign-new-dashboard": "Priskirti naują skydelį", + "assign-dashboards-text": "Priskirti { count, plural, =1 {1 skydelį} other {# skydelius} } klientams", + "unassign-dashboards-action-text": "Atsieti { count, plural, =1 {1 skydelį} other {# skydelius} } nuo klientų", + "delete-dashboards": "Panaikinti skydelius", + "unassign-dashboards": "Atsieti skydelius", + "unassign-dashboards-action-title": "Atsieti { count, plural, =1 {1 skydelį} other {# skydelius} } nuo kliento", + "delete-dashboard-title": "Ar tikrai norite pašalinti skydelį '{{dashboardTitle}}'?", + "delete-dashboard-text": "Būkite dėmesingi, po patvirtinimo, skydelio ir su juo susijusios informacijos atkurti nebegalėsite.", + "delete-dashboards-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 skydelį} other {# skydelius} }?", + "delete-dashboards-action-title": "Pašalinti { count, plural, =1 {1 skydelį} other {# skydelius} }", + "delete-dashboards-text": "Būkite dėmesingi, po patvirtinimo, pasirinkti skydeliai ir su jais susijusi informacija bus pašalinti ir jų atkurti nebegalėsite.", + "unassign-dashboard-title": "Ar tikrai norite atsieti skydelį '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Po patvrtinimo skydelis bus atsietas ir klientas jo nebematys.", + "unassign-dashboard": "Atsieti skydelį", + "unassign-dashboards-title": "Ar tikrai norite atsieti { count, plural, =1 {1 skydelį} other {# skydelius} }?", + "unassign-dashboards-text": "Po patvirtinimo visi skydeliai bus atsieti ir klientas jų nebematys.", + "public-dashboard-title": "Skydelis dabar yra viešas", + "public-dashboard-text": "Skydelis {{dashboardTitle}} dabar yra viešas ir pasiekiamas per šią nuorodą:", + "public-dashboard-notice": "Pastaba: Įrenginiai turi būti vieši, jei norite matyti jų duomenis.", + "public-dashboard-link": "Vieša skydelio nuoroda", + "public-dashboard-link-text": "Viešą sydelį {{dashboardTitle}} galima atidaryti per šią nuorodą:", + "public-dashboard-link-notice": "Pastaba: Įrenginiai, turtas ir subjektai turi būti vieši, jei norite matyti jų duomenis.", + "make-private-dashboard-title": "Ar tikrai norite skydelį '{{dashboardTitle}}' padaryti privačiu?", + "make-private-dashboard-text": "Po patvirtinimo, skydelis taps privačiu ir jo nematys kiti vartotojai.", + "make-private-dashboard": "Skydelį padaryti privačiu", + "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "select-dashboard": "Pasirinkite skydelį", + "no-dashboards-matching": "Skydelio, atitinkančio '{{entity}}' nėra.", + "dashboard-required": "Skydelis būtinas.", + "select-existing": "Pasirinkite egzistuojantį skydelį", + "create-new": "Sukurti naują skydelį", + "new-dashboard-title": "Naujas skydelio pavadinimas", + "open-dashboard": "Atidaryti skydelį", + "set-background": "Nustatyti foną", + "background-color": "Fono spalva", + "background-image": "Fono paveikslėlis", + "background-size-mode": "Fono dydis", + "no-image": "Paveikslėlis nepasirinktas", + "empty-image": "Nėra paveikslėlio", + "drop-image": "Užvilkite paveikslėlį arba spustelėkite ir pasirinkite failą.", + "maximum-upload-file-size": "Maksimalus įkeliamo failo dydis: {{ size }}", + "cannot-upload-file": "Failo įkelti nepavyko", + "settings": "Nustatymai", + "layout-settings": "Išdėstymo nustatymai", + "columns-count": "Stulpelių skaičius", + "columns-count-required": "Stulpelių skaičius būtinas.", + "min-columns-count-message": "Minimalus stulpelių skaičius - 10.", + "max-columns-count-message": "Maksimalus stulpelių skaičius - 1000.", + "widgets-margins": "Paraštės tarp valdiklių dysis", + "margin-required": "Paraštės dydžio reikšmė būtina.", + "min-margin-message": "Minimali paraštės dydžio reikšmė - 0.", + "max-margin-message": "Maksimali paraštės dydžio reikšmė - 50.", + "horizontal-margin": "Horizontalios paraštės dydis", + "horizontal-margin-required": "Horizontalios paraštės dydžio reikšmė būtina.", + "min-horizontal-margin-message": "Minimali horizontalios paraštės dydžio reikšmė - 0", + "max-horizontal-margin-message": "Maksimali horizontalios paraštės dydžio reikšmė - 50.", + "vertical-margin": "Vertikalios paraštės dydis", + "vertical-margin-required": "Vertikalios paraštės dydžio reikšmė būtina.", + "min-vertical-margin-message": "Minimali vertikalios paraštės dydžio reikšmė - 0.", + "max-vertical-margin-message": "Maksimali vertikalios paraštės dydžio reikšmė - 50.", + "apply-outer-margin": "Taikyti paraštes maketo šonuose", + "autofill-height": "Automatiškai pritaikyti aukštį", + "mobile-layout": "Mobiliojo režimo išdėstymo nustatymai", + "mobile-row-height": "Mobiliojo režimo eilutės aukštis pikseliais", + "mobile-row-height-required": "Mobiliojo režimo eilutės aukštis būtinas.", + "min-mobile-row-height-message": "Minimalus mobiliojo režimo eilutės aukštis yra 5 pikseliai.", + "max-mobile-row-height-message": "Maksimalus mobiliojo režimo eilutės aukštis yra 200 pikselių.", + "title-settings": "Pavadinimo nustatymai", + "display-title": "Rodyt skydelio pavadinimą", + "title-color": "Pavadinimo spalva", + "toolbar-settings": "Įrankių juostos nustatymai", + "hide-toolbar": "Paslėpti įrankių juostą", + "toolbar-always-open": "Įrankių juostą laikyti atdarytą", + "display-dashboards-selection": "Rodyti skydelių pasirinkimą", + "display-entities-selection": "Rodyti subjektų pasirinkimą", + "display-filters": "Rodyti filtrus", + "display-dashboard-timewindow": "Rodyti laiko langą", + "display-dashboard-export": "Rodyti eksportą", + "display-update-dashboard-image": "Display update dashboard image", + "dashboard-logo-settings": "Dashboard logo settings", + "display-dashboard-logo": "Display logo in dashboard fullscreen mode", + "dashboard-logo-image": "Dashboard logo image", + "advanced-settings": "Advanced settings", + "dashboard-css": "Dashboard CSS", + "import": "Importuoti skydelį", + "export": "Eksportuoti skydelį", + "export-failed-error": "Skydelio importuoti nepavyko: {{error}}", + "export-pdf": "Eksportuoti kaip PDF", + "export-png": "Eksportuoti kaip PNG", + "export-jpg": "Eksportuoti kaip JPEG", + "export-json-config": "Eksportuoti JSON konfigūraciją", + "download-dashboard-progress": "Generuojamas skydelis {{reportType}} ...", + "create-new-dashboard": "Sukurti naują skydelį", + "dashboard-file": "Skydelio failas", + "invalid-dashboard-file-error": "Skydelio importuoti nepavyko: neteisinga duomenų struktūra.", + "dashboard-import-missing-aliases-title": "Konfigūruoti importuoto skydelio naudojamus pseudonimus", + "create-new-widget": "Sukurti naują valdiklį", + "import-widget": "importuoti valdiklį", + "widget-file": "valdiklio failas", + "invalid-widget-file-error": "Valdiklio importuoti nepavyko: neteisinga duomenų struktūra.", + "widget-import-missing-aliases-title": "Konfigūruoti importuoto valdiklio naudojamus pseudonimus", + "open-toolbar": "Atidaryti skydelio įrankių juostą", + "close-toolbar": "Uždaryti įrankių juostą", + "configuration-error": "Konfigūracijos klaida", + "alias-resolution-error-title": "Skydelio pseudonimų konfigūracijos klaida", + "invalid-aliases-config": "Įrenginių, atitinkančių pseudonimų filtrus, nėra.
Susisiekite su sistemos administratoriumi.", + "select-devices": "Pasirinkite įrenginius", + "assignedToCustomer": "Priskirta klientui", + "assignedToCustomers": "Priskirta klientams", + "public": "Viešas", + "copyId": "Kopijuoti skydelio Id", + "idCopiedMessage": "Skydelio Id nukopijuotas į iškarpinę", + "public-link": "Vieša nuoroda", + "copy-public-link": "Kopijuoti viešą nuorodą", + "public-link-copied-message": "Skydelio vieša nuoroda nukopijuota į iškarpinę", + "manage-states": "Valdyti skydelio būsenas", + "states": "Skydelio būsenos", + "states-short": "Būsenos", + "search-states": "Skydelio būsenų paieška", + "selected-states": "Pasirinkta skydelio { count, plural, =1 {1 būsena} other {# būsenos} }", + "edit-state": "Redaguoti skydelio būseną", + "delete-state": "Panaikinti skydelio būseną", + "add-state": "Pridėti skydelio būseną", + "no-states-text": "Būsenų nėra", + "state": "Skydelio būsena", + "state-name": "Pavadinimas", + "state-name-required": "Skydelio pavadinimas būtinas.", + "state-id": "Būsenos Id", + "state-id-required": "Būsenos Id būtinas.", + "state-id-exists": "Skydelio būsena su tokiu pačiu Id jau yra.", + "is-root-state": "Pagrindinė būsena", + "delete-state-title": "Panaikinti skydelio būseną", + "delete-state-text": "Ar tikrai norite panaikinti skydelio būseną '{{stateName}}'?", + "show-details": "Rodyti detales", + "hide-details": "Slėpti detales", + "select-state": "Pasirinkite būseną", + "state-controller": "Būsenų valdiklis", + "search": "Skydelių paieška", + "selected-dashboards": "Pasirinkta { count, plural, =1 {1 skydelis} other {# skydeliai} }", + "home-dashboard": "Pagrindinis skydelis", + "home-dashboard-hide-toolbar": "Slėpti pagrindinio skydelio įrankių juostą", + "select-group-to-add": "Pasirinkite grupę, prie kurios pridėti pažymėtus skydelius", + "select-group-to-move": "Pasirinkite grupę, į kurią perkelti pasirinktus skydelius", + "remove-dashboards-from-group": "Ar tikrai pašalinti { count, plural, =1 {1 skydelį} other {# skydelius} } iš grupės '{{entityGroup}}'?", + "group": "Skydelių grupė", + "list-of-groups": "{ count, plural, =1 {Viena skydelių grupė} other {Sąrašas # skydelių grupių} }", + "group-name-starts-with": "Skydelių grupės, kurių pavadinimai prasideda '{{prefix}}'", + "unassign-dashboard-from-edge-text": "After the confirmation the dashboard will be unassigned and won't be accessible by the edge.", + "unassign-dashboards-from-edge-title": "Are you sure you want to unassign { count, plural, =1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-from-edge-text": "After the confirmation all selected dashboards will be unassigned and won't be accessible by the edge.", + "assign-dashboard-to-edge": "Assign Dashboard(s) To Edge", + "assign-dashboard-to-edge-text": "Please select the dashboards to assign to the edge", + "non-existent-dashboard-state-error": "Dashboard state with id \"{{ stateId }}\" is not found", + "edit-mode": "Edit mode" + }, + "datakey": { + "settings": "Nustatymai", + "general": "Bendra", + "advanced": "Patyrusiam vartotojui", + "key": "Raktas", + "label": "Etiketė", + "color": "Spalva", + "units": "Specialieji simboliai, rodomi šalia reikšmės", + "decimals": "Skaičių kiekis po kablelio", + "data-generation-func": "Duomenų generavimo funkcija", + "use-data-post-processing-func": "Papildomo duomenų apdorojimo funkcija", + "configuration": "Duomenų raktų konfigūracija", + "timeseries": "Telemetrija", + "attributes": "Atributai", + "entity-field": "Subjekto laukas", + "alarm": "Įspėjimo laukai", + "timeseries-required": "Subjekto telemetrija būtina.", + "timeseries-or-attributes-required": "Subjekto telemetrija/atributai būtini.", + "alarm-fields-timeseries-or-attributes-required": "Įspėjimo laukai ar subjekto telemetrijos/atributai būtini.", + "maximum-timeseries-or-attributes": "Leidžiama daugiausiai { count, plural, =1 {1 viena telemetrija/atributas.} other {# telemetrijos/atributai} }", + "alarm-fields-required": "Įspėjimo laukai būtini.", + "function-types": "Funkcijų tipai", + "function-type": "Funkcijos tipas", + "function-types-required": "Funkcijos tipai būtini.", + "data-keys": "Duomenų raktai", + "data-key": "Duomenų raktas", + "data-keys-required": "Duomenų raktai būtini.", + "data-key-required": "Duomenų raktas būtinas.", + "alarm-keys": "Įspėjimo duomenų raktai", + "alarm-key": "Įspėjimo duomenų raktas", + "alarm-key-functions": "Alarm key functions", + "alarm-key-function": "Alarm key function", + "latest-keys": "Latest data keys", + "latest-key": "Latest data key", + "latest-key-functions": "Latest key functions", + "latest-key-function": "Latest key function", + "timeseries-keys": "Timeseries data keys", + "timeseries-key": "Timeseries data key", + "timeseries-key-functions": "Timeseries key functions", + "timeseries-key-function": "Timeseries key function", + "maximum-function-types": "Daugiausiai leidžiama { count, plural, =1 {1 funkcijos tipas.} other {# funkcijos tipai} }", + "time-description": "Dabartinės vertės laiko žyma;", + "value-description": "Dabartinė vertė;", + "prev-value-description": "Ankstesnio funkcijos kvietimo rezultatas;", + "time-prev-description": "Ankstesnės vertės laiko žyma;", + "prev-orig-value-description": "Pradinė ankstesnė vertė;", + "aggregation": "Agregavimas", + "aggregation-type-hint-common": "For performance reasons, the aggregated values calculation is available only for fixed time intervals like \"current day\", \"current month\", etc, and is not available for sliding window intervals like 'last 30 minutes' or 'last 24 hours'.", + "aggregation-type-none-hint": "Take latest value.", + "aggregation-type-min-hint": "Find minimum value among data points within a selected time window.", + "aggregation-type-max-hint": "Find maximum value among data points within a selected time window.", + "aggregation-type-avg-hint": "Calculate an average value among data points within a selected time window.", + "aggregation-type-sum-hint": "Sum all values of the data points within a selected time window.", + "aggregation-type-count-hint": "Total number of the data points within a selected time window.", + "delta-calculation": "Delta calculation", + "enable-delta-calculation": "Enable delta calculation", + "enable-delta-calculation-hint": "When enabled, the data key value is calculated based on the aggregated values for a selected time window and a specified comparison period. For performance reasons, the delta calculation is available only for history time windows and not for real-time values. For example, you may calculate the delta between the energy consumption for yesterday compared to the energy consumption for the day before yesterday.", + "delta-calculation-result": "Delta calculation result", + "delta-calculation-result-previous-value": "Previous value", + "delta-calculation-result-delta-absolute": "Delta (absolute)", + "delta-calculation-result-delta-percent": "Delta (percent)", + "source": "Source", + "latest": "Latest", + "latest-value": "Latest value", + "delta": "delta", + "percent": "percent", + "absolute": "absolute" + }, + "datasource": { + "type": "Duomenų šaltinio tipas", + "name": "Pavadinimas", + "label": "Etiketė", + "add-datasource-prompt": "Pridėkite duomenų šaltinį" + }, + "details": { + "details": "Informacija", + "edit-mode": "Redagavimo režimas", + "edit-json": "Redaguoti JSON", + "toggle-edit-mode": "Redagavimo režimas" + }, + "device": { + "all": "Visi", + "all-devices": "Visi įrenginiai", + "groups": "Grupės", + "shared": "Bendrinami", + "device": "Įrenginys", + "device-required": "Įrenginys būtinas.", + "devices": "Įrenginiai", + "management": "Įrenginių valdymas", + "view-devices": "Peržiūrėti įrenginius", + "device-alias": "Įrenginio pseudonimas", + "device-type-max-length": "Įrenginio tipas negali viršyti 256 simbolių", + "aliases": "Įrenginių pseudonimai aliases", + "no-alias-matching": "'{{alias}}' nerasta.", + "no-aliases-found": "Pseudonimų nėra.", + "no-key-matching": "'{{key}}' nerasta.", + "no-keys-found": "Raktų nėra.", + "create-new-alias": "Sukurti naują!", + "create-new-key": "Sukurti naują!", + "duplicate-alias-error": "'{{alias}}' dubliuojasi.
Įrenginio pseudonimai skydelyje turi būti unikalūs.", + "configure-alias": "Sukonfigūruoti '{{alias}}' pseudonimą", + "no-devices-matching": "Įrenginių, attinkančių '{{entity}}' nėra.", + "alias": "Pseudonimas", + "alias-required": "Įrenginio pseudonimas būtinas.", + "remove-alias": "Panaikinti įrenginio pseudonimą", + "add-alias": "Pridėti įrenginio pseudonimą", + "name-starts-with": "Įrenginio pavadinimas prasideda", + "help-text": "Naudokite '%' simbolį pagal tai, kaip norite ieškoti: '%įrenginio_pavadinimo_fragmentas%', '%įrenginio_pavadinimo_pabaiga', 'įrenginio_pavadinimo_pradžia%'.", + "device-list": "Įrenginių sąrašas", + "use-device-name-filter": "Naudoti filtrą", + "device-list-empty": "Įrenginiai nepasirinkti.", + "device-name-filter-required": "Įrenginio pavadinimo filtras turi būti nustatytas.", + "device-name-filter-no-device-matched": "Įrenginių, kurių pavadinimas prasideda '{{device}}' nėra.", + "add": "Pridėti įrenginį", + "assign-to-customer": "Priskirti kientui", + "assign-device-to-customer": "Įrenginį (-ius) priskirti klientui", + "assign-device-to-customer-text": "Pasirinkite įrenginius, kuriuos norite priskirti klientui", + "make-public": "Įrenginį padaryti viešu", + "make-private": "Įrenginį padaryti privačiu", + "no-devices-text": "Įrenginių nėra", + "assign-to-customer-text": "Pasirinkite klientą, kuriam norite priskirti įrenginį (-ius)", + "device-details": "Įrenginio informacija", + "add-device-text": "Pridėti naują įrenginį", + "credentials": "Įgaliojimai", + "manage-credentials": "Valdyti įgaliojimus", + "delete": "Panaikinti įrenginį", + "assign-devices": "priskirti įrenginius", + "assign-devices-text": "Priskirti { count, plural, =1 {1 įrenginį} other {# įrenginius} } klientui", + "delete-devices": "Panaikinti įrenginius", + "unassign-from-customer": "Atseti nuo kliento", + "unassign-devices": "Atsieti įrenginius", + "unassign-devices-action-title": "Atsieti { count, plural, =1 {1 įrenginį} other {# įrenginius} } nuo kliento", + "unassign-device-from-edge-title": "Are you sure you want to unassign the device '{{deviceName}}'?", + "unassign-device-from-edge-text": "After the confirmation the device will be unassigned and won't be accessible by the edge.", + "unassign-devices-from-edge": "Unassign devices from edge", + "assign-new-device": "Priskirti naują įrenginį", + "make-public-device-title": "Ar tikrai norite įrenginį '{{deviceName}}' padaryti viešu?", + "make-public-device-text": "Po patvirtinimo įrenginys ir visi jo duomenys bus vieši ir prieinami kitiems vartotojams.", + "make-private-device-title": "Ar tikrai norite įrenginį '{{deviceName}}' padaryti privačiu?", + "make-private-device-text": "Po patvirtinimo įrenginys ir visi jo duomenys taps privatūs ir nebus prieinami kitiems vartotojams.", + "view-credentials": "Peržiūrėti įgaliojimus", + "delete-device-title": "Ar tikrai norite panaikinti įrenginį '{{deviceName}}'?", + "delete-device-text": "Būkite dėmesingi, po patvirtinimo įrenginys ir visa su juo susijusi informacija bus panaikinta ir jų atkurti nebegalėsite", + "delete-devices-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 įrenginį} other {# įrenginius} }?", + "delete-devices-action-title": "Panaikinti { count, plural, =1 {1 įrenginį} other {# įrenginius} }", + "delete-devices-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti įrenginiai ir su jais susijusi informacija bus panaikinta ir jų atkurti nebegalėsite.", + "unassign-device-title": "Ar tikrai norite atsieti įrenginį '{{deviceName}}'?", + "unassign-device-text": "Po patvirtinimo įrenginys bus atsietas ir klientas jo nebematys.", + "unassign-device": "Atsieti įrenginį", + "unassign-devices-title": "Ar tikrai norite atsieti { count, plural, =1 {1 įrenginį} other {# įrenginius} }?", + "unassign-devices-text": "Po patvirtinimo visi pasirinkti įrenginiai bus atsieti nuo kliento.", + "device-credentials": "įrenginio įgaliojimai", + "loading-device-credentials": "Įkeliami įrenginio įgaliojimai...", + "credentials-type": "Įgaliojimų tipas", + "access-token": "Prieigos raktas", + "access-token-required": "Prieigos raktas būtinas.", + "access-token-invalid": "Prieigos rakto ilgis turi būti nuo 1 iki 32 simbolių.", + "certificate-pem-format": "Certificate in PEM format", + "certificate-pem-format-required": "Certificate is required.", + "copy-access-token": "Copy Access token", + "copy-certificate": "Copy Certificate", + "copy-client-id": "Copy Client ID", + "copy-user-name": "Copy User name", + "copy-password": "Copy Password", + "generate-client-id": "Generate Client ID", + "generate-user-name": "Generate User name", + "generate-password": "Generate Password", + "generate-access-token": "Generate Access Token", + "lwm2m-security-config": { + "identity": "Client Identity", + "identity-required": "Client Identity is required.", + "identity-tooltip": "The PSK identifier is an arbitrary PSK identifier up to 128 bytes, as described in the standard [RFC7925].\nThe PSK identifier MUST first be converted to a character string and then encoded into octets using UTF-8.", + "client-key": "Client Key", + "client-key-required": "Client Key is required.", + "client-key-tooltip-prk": "RPK public key or id must be in the standard [RFC7250] and encoded to Base64 format!", + "client-key-tooltip-psk": "PSK key must be in the standard [RFC4279] and HexDec format: 32, 64, 128 characters!", + "endpoint": "Endpoint Client Name", + "endpoint-required": "Endpoint Client Name is required.", + "client-public-key": "Client public key", + "client-public-key-hint": "If client public key is empty, the trusted certificate will be used", + "client-public-key-tooltip": "X509 public key must be in DER-encoded X509v3 format and support exclusively EC algorithm and then encoded to Base64 format!", + "mode": "Security config mode", + "client-tab": "Client Security Config", + "client-certificate": "Client certificate", + "bootstrap-tab": "Bootstrap Client", + "bootstrap-server": "Bootstrap Server", + "lwm2m-server": "LwM2M Server", + "client-publicKey-or-id": "Client Public Key or Id", + "client-publicKey-or-id-required": "Client Public Key or Id is required.", + "client-publicKey-or-id-tooltip-psk": "The PSK identifier is an arbitrary PSK identifier up to 128 bytes, as described in the standard [RFC7925].\nThe PSK identifier MUST first be converted to a character string and then encoded into octets using UTF-8.", + "client-publicKey-or-id-tooltip-rpk": "RPK public key or id must be in the standard [RFC7250] and encoded to Base64 format!", + "client-publicKey-or-id-tooltip-x509": "X509 public key must be in DER-encoded X509v3 format and support exclusively EC algorithm and then encoded to Base64 format", + "client-secret-key": "Client Secret Key", + "client-secret-key-required": "Client Secret Key is required.", + "client-secret-key-tooltip-psk": "PSK key must be in the standard [RFC4279] and HexDec format: 32, 64, 128 characters!", + "client-secret-key-tooltip-prk": "RPK secret key must be in PKCS_8 format (DER encoding, standard [RFC5958]) and then encoded to Base64 format!", + "client-secret-key-tooltip-x509": "X509 secret key must be in PKCS_8 format (DER encoding, standard [RFC5958]) and then encoded to Base64 format!" + }, + "client-id": "Client ID", + "client-id-pattern": "Contains invalid character.", + "user-name": "User Name", + "user-name-required": "User Name is required.", + "client-id-or-user-name-necessary": "Client ID and/or User Name are necessary", + "password": "Password", + "secret": "Secret", + "secret-required": "Secret is required.", + "device-type": "Įrenginio tipas", + "device-type-required": "Įrenginio tipas būtinas.", + "select-device-type": "Pasirinkite įrenginio tipą", + "enter-device-type": "Įveskite įrenginio tipą", + "any-device": "Bet kuris įrenginys", + "no-device-types-matching": "Įrenginio tipų, atitinkančių '{{entitySubtype}}', nėra.", + "device-type-list-empty": "Nepasirinktas įrenginio tipas!", + "device-types": "Įrenginių tipai", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "label-max-length": "Etiketė negali viršyti 256 simbolių", + "description": "Aprašymas", + "label": "Etiketė", + "events": "Įvykiai", + "details": "Informacija", + "copyId": "Kopijuoti įrenginio Id", + "copyAccessToken": "Kopijuoti prieigos raktą", + "copy-mqtt-authentication": "Copy MQTT credentials", + "idCopiedMessage": "Įrenginio Id nukopijuotas į iškarpinę", + "accessTokenCopiedMessage": "Įrenginio prieigos raktas nukopijuotas į iškarpinę", + "mqtt-authentication-copied-message": "Device MQTT authentication has been copied to clipboard", + "assignedToCustomer": "Priskirtas klientui", + "unable-delete-device-alias-title": "Įrenginio pseudonimo panaikinti nepavyko", + "unable-delete-device-alias-text": "Įrenginio pseudonimas '{{deviceAlias}}' nes jis naudojamas šiuose valdikliuose:
{{widgetsList}}", + "is-gateway": "Is gateway", + "overwrite-activity-time": "Overwrite activity time for connected device", + "device-filter": "Įrenginių filtras", + "device-filter-title": "Įrenginių filtras", + "filter-title": "Filtras", + "device-state": "Įrenginio būsena", + "state": "Būsena", + "any": "Visos", + "active": "Aktyvus", + "inactive": "Neaktyvus", + "public": "Viešas", + "device-public": "Įrenginys yra viešas", + "select-device": "Pasirinkite įrenginį", + "select-group-to-add": "Pasirinkite grupę, į kurią norite pridėti pažymėtus įrenginius", + "select-group-to-move": "Pasirinkite grupę, į kurią norite perkelti pažymėtus įrenginius", + "remove-devices-from-group": "Ar tikrai norite iš grupės '{{entityGroup}}' pašalinti { count, plural, =1 {1 įrenginį} other {# įrenginius} }?", + "group": "Įrenginių grupė", + "list-of-groups": "{ count, plural, =1 {Viena įrenginio grupė} other {# įrenginių grupių sąrašas} }", + "group-name-starts-with": "Įrenginių grupės, kurių pavadinimai prasideda '{{prefix}}'", + "import": "Importuoti įrenginį", + "device-file": "Įrenginio failas", + "search": "Įrenginių paieška", + "selected-devices": "Pasirinkta { count, plural, =1 {1 įrenginys} other {# įrenginiai} }", + "device-configuration": "Įrenginių konfigūracija", + "transport-configuration": "Transport configuration", + "wizard": { + "device-details": "Informacija apie įrenginį" + }, + "unassign-devices-from-edge-title": "Are you sure you want to unassign { count, plural, =1 {1 device} other {# devices} }?", + "unassign-devices-from-edge-text": "After the confirmation all selected devices will be unassigned and won't be accessible by the edge.", + "time": "Time", + "connectivity": { + "check-connectivity": "Check connectivity", + "device-created-check-connectivity": "Device created. Let's check connectivity!", + "loading-check-connectivity-command": "Loading check connectivity commands...", + "use-following-instructions": "Use the following instructions for sending telemetry on behalf of the device using shell", + "execute-following-command": "Execute the following command", + "install-curl-windows": "Starting Windows 10 b17063, cURL is available by default", + "install-curl-macos": "Starting Mac OS X 10.2 6C115 (Jaguar), cURL is available by default", + "install-mqtt-windows": "Use the instructions to download, install, setup and run mosquitto_pub", + "install-coap-client": "Use the instructions to download, install, setup and run coap-client", + "install-necessary-client-tools": "Install necessary client tools", + "mqtts-x509-command": "Use the following documentation to connect the device via MQTT with authorization X509", + "coaps-x509-command": "Use the following documentation to connect the device via CoAP over DTLS with authorization X509", + "snmp-command": "Use the following documentation to connect the device through the SNMP.", + "sparkplug-command": "Use the following documentation to connect the device through the MQTT Sparkplug.", + "lwm2m-command": "Use the following documentation to connect the device through the LWM2M." + } + }, + "asset-profile": { + "asset-profile": "Profilis", + "asset-profiles": "Asset profiles", + "all-asset-profiles": "Visi", + "add": "Add asset profile", + "edit": "Edit asset profile", + "asset-profile-details": "Asset profile details", + "no-asset-profiles-text": "No asset profiles found", + "search": "Search asset profiles", + "selected-asset-profiles": "{ count, plural, =1 {1 asset profile} other {# asset profiles} } selected", + "no-asset-profiles-matching": "No asset profile matching '{{entity}}' were found.", + "asset-profile-required": "Asset profile is required", + "idCopiedMessage": "Asset profile Id has been copied to clipboard", + "set-default": "Make asset profile default", + "delete": "Delete asset profile", + "copyId": "Copy asset profile Id", + "name-max-length": "Name should be less than 256", + "new-device-profile-name": "Asset profile name", + "new-device-profile-name-required": "Asset profile name is required.", + "name": "Name", + "name-required": "Name is required.", + "image": "Asset profile image", + "description": "Description", + "default": "Default", + "default-rule-chain": "Default rule chain", + "default-edge-rule-chain": "Default edge rule chain", + "default-edge-rule-chain-hint": "Used on edge as rule chain to process incoming data for assets of this asset profile", + "mobile-dashboard": "Mobile dashboard", + "mobile-dashboard-hint": "Used by mobile application as a asset details dashboard", + "select-queue-hint": "Select from a drop-down list.", + "delete-asset-profile-title": "Are you sure you want to delete the asset profile '{{assetProfileName}}'?", + "delete-asset-profile-text": "Be careful, after the confirmation the asset profile and all related data will become unrecoverable.", + "delete-asset-profiles-title": "Are you sure you want to delete { count, plural, =1 {1 asset profile} other {# asset profiles} }?", + "delete-asset-profiles-text": "Be careful, after the confirmation all selected asset profiles will be removed and all related data will become unrecoverable.", + "set-default-asset-profile-title": "Are you sure you want to make the asset profile '{{assetProfileName}}' default?", + "set-default-asset-profile-text": "After the confirmation the asset profile will be marked as default and will be used for new assets with no profile specified.", + "no-asset-profiles-found": "No asset profiles found.", + "create-new-asset-profile": "Create a new one!", + "create-asset-profile": "Create new asset profile", + "import": "Import asset profile", + "export": "Export asset profile", + "export-failed-error": "Unable to export asset profile: {{error}}", + "asset-profile-file": "Asset profile file", + "invalid-asset-profile-file-error": "Unable to import asset profile: Invalid asset profile data structure." + }, + "device-profile": { + "device-profile": "Profilis", + "device-profiles": "Device profiles", + "all-device-profiles": "Visi", + "add": "Add device profile", + "edit": "Edit device profile", + "device-profile-details": "Device profile details", + "no-device-profiles-text": "No device profiles found", + "search": "Search device profiles", + "selected-device-profiles": "{ count, plural, =1 {1 device profile} other {# device profiles} } selected", + "no-device-profiles-matching": "No device profile matching '{{entity}}' were found.", + "device-profile-required": "Device profile is required", + "idCopiedMessage": "Device profile Id has been copied to clipboard", + "set-default": "Make device profile default", + "delete": "Delete device profile", + "copyId": "Copy device profile Id", + "name-max-length": "Name should be less than 256", + "name": "Name", + "name-required": "Name is required.", + "type": "Profile type", + "type-required": "Profile type is required.", + "type-default": "Default", + "image": "Device profile image", + "transport-type": "Transport type", + "transport-type-required": "Transport type is required.", + "transport-type-default": "Default", + "transport-type-default-hint": "Supports basic MQTT, HTTP and CoAP transport", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Enables advanced MQTT transport settings", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Enables advanced CoAP transport settings", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M transport type", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Specify SNMP transport configuration", + "transport-type-http": "HTTP", + "description": "Description", + "default": "Default", + "profile-configuration": "Profile configuration", + "transport-configuration": "Transport configuration", + "default-rule-chain": "Default rule chain", + "default-edge-rule-chain": "Default edge rule chain", + "default-edge-rule-chain-hint": "Used on edge as rule chain to process incoming data for devices of this device profile", + "mobile-dashboard": "Mobile dashboard", + "mobile-dashboard-hint": "Used by mobile application as a device details dashboard", + "select-queue-hint": "Select from a drop-down list.", + "delete-device-profile-title": "Are you sure you want to delete the device profile '{{deviceProfileName}}'?", + "delete-device-profile-text": "Be careful, after the confirmation the device profile and all related data including associated OTA updates will become unrecoverable.", + "delete-device-profiles-title": "Are you sure you want to delete { count, plural, =1 {1 device profile} other {# device profiles} }?", + "delete-device-profiles-text": "Be careful, after the confirmation all selected device profiles will be removed and all related data including associated OTA updates will become unrecoverable.", + "set-default-device-profile-title": "Are you sure you want to make the device profile '{{deviceProfileName}}' default?", + "set-default-device-profile-text": "After the confirmation the device profile will be marked as default and will be used for new devices with no profile specified.", + "no-device-profiles-found": "No device profiles found.", + "create-new-device-profile": "Create a new one!", + "mqtt-device-topic-filters": "MQTT device topic filters", + "mqtt-device-topic-filters-unique": "MQTT device topic filters need to be unique.", + "mqtt-device-topic-filters-spark-plug": "MQTT Sparkplug B Edge of Network (EoN) node.", + "mqtt-device-topic-filters-spark-plug-hint": "Allow connections from EoN nodes with Sparkplug B payload and topic format.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "SparkPlug metrics to store as attributes.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "Names of SparkPlug metrics that will be stored as device attributes. All other metrics will be stored as device telemetry.", + "mqtt-device-payload-type": "MQTT device payload", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "Enable compatibility with other payload formats.", + "mqtt-enable-compatibility-with-json-payload-format-hint": "When enabled, the platform will use a Protobuf payload format by default. If parsing fails, the platform will attempt to use JSON payload format. Useful for backward compatibility during firmware updates. For example, the initial release of the firmware uses Json, while the new release uses Protobuf. During the process of firmware update for the fleet of devices, it is required to support both Protobuf and JSON simultaneously. The compatibility mode introduces slight performance degradation, so it is recommended to disable this mode once all devices are updated.", + "mqtt-use-json-format-for-default-downlink-topics": "Use Json format for default downlink topics", + "mqtt-use-json-format-for-default-downlink-topics-hint": "When enabled, the platform will use Json payload format to push attributes and RPC via the following topics: v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes, v1/devices/me/rpc/request/$request_id, v1/devices/me/rpc/response/$request_id. This setting does not impact attribute and rpc subscriptions sent using new (v2) topics: v2/a/res/$request_id, v2/a, v2/r/req/$request_id, v2/r/res/$request_id. Where $request_id is an integer request identifier.", + "mqtt-send-ack-on-validation-exception": "Send PUBACK on PUBLISH message validation failure", + "mqtt-send-ack-on-validation-exception-hint": "By default, the platform will close the MQTT session on message validation failure. When enabled, the platform will send publish acknowledgment instead of closing the session.", + "snmp-add-mapping": "Add SNMP mapping", + "snmp-mapping-not-configured": "No mapping for OID to timeseries/telemetry configured", + "snmp-timseries-or-attribute-name": "Timeseries/attribute name for mapping", + "snmp-timseries-or-attribute-type": "Timeseries/attribute type for mapping", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Payload type is required.", + "coap-device-type": "CoAP device type", + "coap-device-payload-type": "CoAP device payload", + "coap-device-type-required": "CoAP device type is required.", + "coap-device-type-default": "Default", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Single [+] and multi-level [#] wildcards supported.", + "telemetry-topic-filter": "Telemetry topic filter", + "telemetry-topic-filter-required": "Telemetry topic filter is required.", + "attributes-topic-filter": "Attributes publish topic filter", + "attributes-subscribe-topic-filter": "Attributes subscribe topic filter", + "attributes-topic-filter-required": "Attributes publish topic filter is required.", + "attributes-subscribe-topic-filter-required": "Attributes subscribe topic is required", + "telemetry-proto-schema": "Telemetry proto schema", + "telemetry-proto-schema-required": "Telemetry proto schema is required.", + "attributes-proto-schema": "Attributes proto schema", + "attributes-proto-schema-required": "Attributes proto schema is required.", + "rpc-response-proto-schema": "RPC response proto schema", + "rpc-response-proto-schema-required": "RPC response proto schema is required.", + "rpc-response-topic-filter": "RPC response topic filter", + "rpc-response-topic-filter-required": "RPC response topic filter is required.", + "rpc-request-proto-schema": "RPC request proto schema", + "rpc-request-proto-schema-required": "RPC request proto schema is required.", + "rpc-request-proto-schema-hint": "RPC request message should always have fields: string method = 1; int32 requestId = 2; and params = 3 of any data type.", + "not-valid-pattern-topic-filter": "Not valid pattern topic filter", + "not-valid-single-character": "Invalid use of a single-level wildcard character", + "not-valid-multi-character": "Invalid use of a multi-level wildcard character", + "single-level-wildcards-hint": "[+] is suitable for any topic filter level. Ex.: v1/devices/+/telemetry or +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] can replace the topic filter itself and must be the last symbol of the topic. Ex.: # or v1/devices/me/#.", + "alarm-rules": "Alarm rules", + "alarm-rules-with-count": "Alarm rules ({{count}})", + "no-alarm-rules": "No alarm rules configured", + "add-alarm-rule": "Add alarm rule", + "edit-alarm-rule": "Edit alarm rule", + "alarm-type": "Alarm type", + "alarm-type-required": "Alarm type is required.", + "alarm-type-unique": "Alarm type must be unique within the device profile alarm rules.", + "alarm-type-max-length": "Alarm type should be less than 256", + "create-alarm-pattern": "Create {{alarmType}} alarm", + "create-alarm-rules": "Create alarm rules", + "no-create-alarm-rules": "No create conditions configured", + "add-create-alarm-rule-prompt": "Please add create alarm rule", + "clear-alarm-rule": "Clear alarm rule", + "no-clear-alarm-rule": "No clear condition configured", + "add-create-alarm-rule": "Add create condition", + "add-clear-alarm-rule": "Add clear condition", + "select-alarm-severity": "Select alarm severity", + "alarm-severity-required": "Alarm severity is required.", + "condition-duration": "Condition duration", + "condition-duration-value": "Duration value", + "condition-duration-time-unit": "Time unit", + "condition-duration-value-range": "Duration value should be in a range from 1 to 2147483647.", + "condition-duration-value-pattern": "Duration value should be integers.", + "condition-duration-value-required": "Duration value is required.", + "condition-duration-time-unit-required": "Time unit is required.", + "advanced-settings": "Advanced settings", + "alarm-rule-details": "Details", + "alarm-rule-details-hint": "Hint: use ${keyName} to substitute values of the attribute or telemetry keys that are used in alarm rule condition.", + "add-alarm-rule-details": "Add details", + "alarm-rule-mobile-dashboard": "Mobile dashboard", + "alarm-rule-mobile-dashboard-hint": "Used by mobile application as an alarm details dashboard", + "alarm-rule-no-mobile-dashboard": "No dashboard selected", + "propagate-alarm": "Propagate alarm to related entities", + "alarm-rule-relation-types-list": "Relation types to propagate", + "alarm-rule-relation-types-list-hint": "If Propagate relation types are not selected, alarms will be propagated without filtering by relation type.", + "propagate-alarm-to-owner": "Propagate alarm to entity owner (Customer or Tenant)", + "propagate-alarm-to-owner-hierarchy": "Propagate alarm to entity owners hierarchy", + "propagate-alarm-to-tenant": "Propagate alarm to Tenant", + "alarm-details": "Alarm details", + "alarm-rule-condition": "Alarm rule condition", + "enter-alarm-rule-condition-prompt": "Please add alarm rule condition", + "edit-alarm-rule-condition": "Edit alarm rule condition", + "device-provisioning": "Device provisioning", + "provision-strategy": "Provision strategy", + "provision-strategy-required": "Provision strategy is required.", + "provision-strategy-disabled": "Disabled", + "provision-strategy-created-new": "Allow to create new devices", + "provision-strategy-check-pre-provisioned": "Check for pre-provisioned devices", + "provision-device-key": "Provision device key", + "provision-device-key-required": "Provision device key is required.", + "copy-provision-key": "Copy provision key", + "provision-key-copied-message": "Provision key has been copied to clipboard", + "provision-device-secret": "Provision device secret", + "provision-device-secret-required": "Provision device secret is required.", + "copy-provision-secret": "Copy provision secret", + "provision-secret-copied-message": "Provision secret has been copied to clipboard", + "provision-strategy-x509": { + "certificate-chain": "X509 Certificates Chain", + "certificate-chain-hint": "X.509 certificates strategy is used to provision devices by client certificates in two-way TLS communication.", + "allow-create-new-devices": "Create new devices", + "allow-create-new-devices-hint": "If selected new devices will be created and client certificate will be used as device credentials.", + "certificate-value": "Certificate in PEM format", + "certificate-value-required": "Certificate in PEM format is required", + "cn-regex-variable": "CN Regular Expression variable", + "cn-regex-variable-required": "CN Regular Expression variable is required", + "cn-regex-variable-hint": "Required to fetch device name from device's X509 certificate's common name." + }, + "condition": "Condition", + "condition-type": "Condition type", + "condition-type-simple": "Simple", + "condition-type-duration": "Duration", + "condition-during": "During {{during}}", + "condition-during-dynamic": "During \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "Repeating", + "condition-type-required": "Condition type is required.", + "condition-repeating-value": "Count of events", + "condition-repeating-value-range": "Count of events should be in a range from 1 to 2147483647.", + "condition-repeating-value-pattern": "Count of events should be integers.", + "condition-repeating-value-required": "Count of events is required.", + "condition-repeat-times": "Repeats { count, plural, =1 {1 time} other {# times} }", + "condition-repeat-times-dynamic": "Repeats \"{ attribute }\" ({ count, plural, =1 {1 time} other {# times} })", + "schedule-type": "Scheduler type", + "schedule-type-required": "Scheduler type is required.", + "schedule": "Schedule", + "edit-schedule": "Edit alarm schedule", + "schedule-any-time": "Active all the time", + "schedule-specific-time": "Active at a specific time", + "schedule-custom": "Custom", + "schedule-day": { + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday", + "sunday": "Sunday" + }, + "schedule-days": "Days", + "schedule-time": "Time", + "schedule-time-from": "From", + "schedule-time-to": "To", + "schedule-days-of-week-required": "At least one day of week should be selected.", + "create-device-profile": "Create new device profile", + "import": "Import device profile", + "export": "Export device profile", + "export-failed-error": "Unable to export device profile: {{error}}", + "device-profile-file": "Device profile file", + "invalid-device-profile-file-error": "Unable to import device profile: Invalid device profile data structure.", + "power-saving-mode": "Power Saving Mode", + "power-saving-mode-type": { + "default": "Use device profile power saving mode", + "psm": "Power Saving Mode (PSM)", + "drx": "Discontinuous Reception (DRX)", + "edrx": "Extended Discontinuous Reception (eDRX)" + }, + "edrx-cycle": "eDRX cycle", + "edrx-cycle-required": "eDRX cycle is required.", + "edrx-cycle-pattern": "eDRX cycle must be a positive integer.", + "edrx-cycle-min": "Minimum number of eDRX cycle is {{ min }} seconds.", + "paging-transmission-window": "Paging Transmission Window", + "paging-transmission-window-required": "Paging transmission window is required.", + "paging-transmission-window-pattern": "Paging transmission window must be a positive integer.", + "paging-transmission-window-min": "Minimum number ofpPaging transmission window is {{ min }} seconds.", + "psm-activity-timer": "PSM Activity Timer", + "psm-activity-timer-required": "PSM activity timer is required.", + "psm-activity-timer-pattern": "PSM activity timer must be a positive integer.", + "psm-activity-timer-min": "Minimum number of PSM activity timer is {{ min }} seconds.", + "lwm2m": { + "object-list": "Object list", + "object-list-empty": "No objects selected.", + "no-objects-found": "No objects found.", + "no-objects-matching": "No objects matching '{{object}}' were found.", + "model-tab": "LWM2M Model", + "add-new-instances": "Add new instances", + "instances-list": "Instances list", + "instances-list-required": "Instances list is required.", + "instance-id-pattern": "Instance id must be a positive integer.", + "instance-id-max": "Maximum instance id value {{max}}.", + "instance": "Instance", + "resource-label": "#ID Resource name", + "observe-label": "Observe", + "attribute-label": "Attribute", + "telemetry-label": "Telemetry", + "edit-observe-select": "To edit observe select telemetry or attribute", + "edit-attributes-select": "To edit attributes select telemetry or attribute", + "no-attributes-set": "No attributes set", + "key-name": "Key name", + "key-name-required": "Key name is required", + "attribute-name": "Name attribute", + "attribute-name-required": "Name attribute is required.", + "attribute-value": "Attribute value", + "attribute-value-required": "Attribute value is required.", + "attribute-value-pattern": "Attribute value must be a positive integer.", + "edit-attributes": "Edit attributes: {{ name }}", + "view-attributes": "View attributes: {{ name }}", + "add-attribute": "Add attribute", + "edit-attribute": "Edit attribute", + "view-attribute": "View attribute", + "remove-attribute": "Remove attribute", + "delete-server-text": "Be careful, after the confirmation the server configuration will become unrecoverable.", + "delete-server-title": "Are you sure you want to delete the server?", + "mode": "Security config mode", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Bootstrap Server (ShortId...)", + "lwm2m-server-legend": "LwM2M Server (ShortId...)", + "server": "Server", + "short-id": "Short server ID", + "short-id-tooltip": "Server short Id. Used as link to associate server Object Instance.\nThis identifier uniquely identifies each LwM2M Server configured for the LwM2M Client.\nResource MUST be set when the Bootstrap-Server Resource has a value of 'false'.\nThe values ID:0 and ID:65535 values MUST NOT be used for identifying the LwM2M Server.", + "short-id-required": "Short server ID is required.", + "short-id-range": "Short server ID should be in a range from 1 to 65534.", + "short-id-pattern": "Short server ID must be a positive integer.", + "lifetime": "Client registration lifetime", + "lifetime-required": "Client registration lifetime is required.", + "lifetime-pattern": "Client registration lifetime must be a positive integer.", + "default-min-period": "Min period between two notifications (s)", + "default-min-period-tooltip": "The default value the LwM2M Client should use for the Minimum Period of an Observation in the absence of this parameter being included in an Observation.", + "default-min-period-required": "Minimum period is required.", + "default-min-period-pattern": "Minimum period must be a positive integer.", + "notification-storing": "Notification storing when disabled or offline", + "binding": "Binding", + "binding-type": { + "u": "U: Client is reachable via the UDP binding at any time.", + "m": "M: Client is reachable via the MQTT binding at any time.", + "h": "H: Client is reachable via the HTTP binding at any time.", + "t": "T: Client is reachable via the TCP binding at any time.", + "s": "S: Client is reachable via the SMS binding at any time.", + "n": "N: Client MUST send the response to such a request over the Non-IP binding (is supported since LWM2M 1.1).", + "uq": "UQ: UDP connection in queue mode (is not supported since LWM2M 1.1)", + "uqs": "UQS: both UDP and SMS connections active; UDP in queue mode, SMS in standard mode (is not supported since LWM2M 1.1)", + "tq": "TQ: TCP connection in queue mode (is not supported since LWM2M 1.1)", + "tqs": "TQS: both TCP and SMS connections active; TCP in queue mode, SMS in standard mode (is not supported since LWM2M 1.1)", + "sq": "SQ: SMS connection in queue mode (is not supported since LWM2M 1.1)" + }, + "binding-tooltip": "This is the list in the\"binding\" resource of the LwM2M server object - /1/x/7.\nIndicates the supported binding modes in the LwM2M Client.\nThis value SHOULD be the same as the value in the “Supported Binding and Modes” resource in the Device Object (/3/0/16).\nWhile multiple transports are supported, only one transport binding can be used during the entire Transport Session.\nAs an example, when UDP and SMS are both supported, the LwM2M Client and the LwM2M Server can choose to communicate either over UDP or SMS during the entire Transport Session.", + "bootstrap-server": "Bootstrap Server", + "lwm2m-server": "LwM2M Server", + "include-bootstrap-server": "Include Bootstrap Server updates", + "bootstrap-update-title": "You already have configured Bootstrap Server. Are you sure you want to exclude the updates?", + "bootstrap-update-text": "Be careful, after the confirmation the Bootstrap Server configuration data will become unrecoverable.", + "server-host": "Host", + "server-host-required": "Host is required.", + "server-port": "Port", + "server-port-required": "Port is required.", + "server-port-pattern": "Port must be a positive integer.", + "server-port-range": "Port should be in a range from 1 to 65535.", + "server-public-key": "Server Public Key", + "server-public-key-required": "Server Public Key is required.", + "client-hold-off-time": "Hold Off Time", + "client-hold-off-time-required": "Hold Off Time is required.", + "client-hold-off-time-pattern": "Hold Off Time must be a positive integer.", + "client-hold-off-time-tooltip": "Client Hold Off Time for use with a Bootstrap-Server only", + "account-after-timeout": "Account after the timeout", + "account-after-timeout-required": "Account after the timeout is required.", + "account-after-timeout-pattern": "Account after the timeout must be a positive integer.", + "account-after-timeout-tooltip": "Bootstrap-Server Account after the timeout value given by this resource.", + "server-type": "Server type", + "add-new-server-title": "Add new server config", + "add-server-config": "Add server config", + "add-lwm2m-server-config": "Add LwM2M server", + "no-config-servers": "No servers configured", + "others-tab": "Other settings", + "client-strategy": "Client strategy when connecting", + "client-strategy-label": "Strategy", + "client-strategy-only-observe": "Only Observe Request to the client after the initial connection", + "client-strategy-read-all": "Read All Resources & Observe Request to the client after registration", + "fw-update": "Firmware update", + "fw-update-strategy": "Firmware update strategy", + "fw-update-strategy-data": "Push firmware update as binary file using Object 19 and Resource 0 (Data)", + "fw-update-strategy-package": "Push firmware update as binary file using Object 5 and Resource 0 (Package)", + "fw-update-strategy-package-uri": "Auto-generate unique CoAP URL to download the package and push firmware update as Object 5 and Resource 1 (Package URI)", + "sw-update": "Software update", + "sw-update-strategy": "Software update strategy", + "sw-update-strategy-package": "Push binary file using Object 9 and Resource 2 (Package)", + "sw-update-strategy-package-uri": "Auto-generate unique CoAP URL to download the package and push software update using Object 9 and Resource 3 (Package URI)", + "fw-update-resource": "Firmware update CoAP resource", + "fw-update-resource-required": "Firmware update CoAP resource is required.", + "sw-update-resource": "Software update CoAP resource", + "sw-update-resource-required": "Software update CoAP resource is required.", + "config-json-tab": "Json Config Profile Device", + "attributes-name": { + "min-period": "Minimum period", + "max-period": "Maximum period", + "greater-than": "Greater than", + "less-than": "Less than", + "step": "Step", + "min-evaluation-period": "Minimum evaluation period", + "max-evaluation-period": "Maximum evaluation period" + }, + "composite-operations-support": "Supports composite Read/Write/Observe operations" + }, + "snmp": { + "add-communication-config": "Add communication config", + "add-mapping": "Add mapping", + "authentication-passphrase": "Authentication passphrase", + "authentication-passphrase-required": "Authentication passphrase is required.", + "authentication-protocol": "Authentication protocol", + "authentication-protocol-required": "Authentication protocol is required.", + "communication-configs": "Communication configs", + "community": "Community string", + "community-required": "Community string is required.", + "context-name": "Context name", + "data-key": "Data key", + "data-key-required": "Data key is required.", + "data-type": "Data type", + "data-type-required": "Data type is required.", + "engine-id": "Engine ID", + "host": "Host", + "host-required": "Host is required.", + "oid": "OID", + "oid-pattern": "Invalid OID format.", + "oid-required": "OID is required.", + "please-add-communication-config": "Please add communication config", + "please-add-mapping-config": "Please add mapping config", + "port": "Port", + "port-format": "Invalid port format.", + "port-required": "Port is required.", + "privacy-passphrase": "Privacy passphrase", + "privacy-passphrase-required": "Privacy passphrase is required.", + "privacy-protocol": "Privacy protocol", + "privacy-protocol-required": "Privacy protocol is required.", + "protocol-version": "Protocol version", + "protocol-version-required": "Protocol version is required.", + "querying-frequency": "Querying frequency, ms", + "querying-frequency-invalid-format": "Querying frequency must be a positive integer.", + "querying-frequency-required": "Querying frequency is required.", + "retries": "Retries", + "retries-invalid-format": "Retries must be a positive integer.", + "retries-required": "Retries is required.", + "scope": "Scope", + "scope-required": "Scope is required.", + "security-name": "Security name", + "security-name-required": "Security name is required.", + "timeout-ms": "Timeout, ms", + "timeout-ms-invalid-format": "Timeout must be a positive integer.", + "timeout-ms-required": "Timeout is required.", + "user-name": "User name", + "user-name-required": "User name is required." + } + }, + "dialog": { + "close": "Uždaryti dialogo langą", + "error-message-title": "Klaidos pranešimas:", + "error-details-title": "Informacija apie klaidą" + }, + "direction": { + "column": "Stulpelis", + "row": "Eilutė" + }, + "edge": { + "all": "All", + "all-edges": "All edges", + "groups": "Groups", + "shared": "Shared", + "edge": "Edge", + "edge-instances": "Edge instances", + "instances": "Instances", + "edge-file": "Edge file", + "name-max-length": "Name should be less than 256", + "label-max-length": "Label should be less than 256", + "type-max-length": "Type should be less than 256", + "management": "Edge management", + "no-edges-matching": "No edges matching '{{entity}}' were found.", + "add": "Add Edge", + "no-edges-text": "No edges found", + "edge-details": "Edge details", + "add-edge-text": "Add new edge", + "delete": "Delete edge", + "delete-edge-title": "Are you sure you want to delete the edge '{{edgeName}}'?", + "delete-edge-text": "Be careful, after the confirmation the edge and all related data will become unrecoverable.", + "delete-edges-title": "Are you sure you want to edge { count, plural, =1 {1 edge} other {# edges} }?", + "delete-edges-text": "Be careful, after the confirmation all selected edges will be removed and all related data will become unrecoverable.", + "name": "Name", + "name-starts-with": "Edge name starts with", + "name-required": "Name is required.", + "edge-license-key": "Edge License Key", + "edge-license-key-required": "Edge License Key is required.", + "edge-license-key-max-length": "Edge License Key should be less than 31", + "edge-license-key-hint": "IMPORTANT NOTE: default edge license key provided only for evaluation purposes.
This key is active only for 30 days after the activation. White-labeling feature is disabled.
To obtain your license key please navigate to the pricing page and select the best license option for your case.", + "cloud-endpoint": "Cloud Endpoint", + "cloud-endpoint-required": "Cloud Endpoint is required.", + "cloud-endpoint-max-length": "Cloud Endpoint should be less than 256", + "cloud-endpoint-hint": "Edge requires HTTP(s) access to Cloud (ThingsBoard PE) to verify the license key. Please specify Cloud URL that Edge is able to connect to.", + "description": "Description", + "details": "Details", + "events": "Events", + "copy-id": "Copy Edge Id", + "id-copied-message": "Edge Id has been copied to clipboard", + "sync": "Sync Edge", + "edge-required": "Edge required", + "edge-type": "Edge type", + "edge-type-required": "Edge type is required.", + "event-action": "Event action", + "entity-id": "Entity ID", + "select-edge-type": "Select edge type", + "assign-to-customer": "Assign to customer", + "assign-to-customer-text": "Please select the customer to assign the edge(s)", + "assign-edge-to-customer": "Assign Edge(s) To Customer", + "assign-edge-to-customer-text": "Please select the edges to assign to the customer", + "assignedToCustomer": "Assigned to customer", + "edge-public": "Edge is public", + "assigned-to-customer": "Assigned to: {{customerTitle}}", + "unassign-from-customer": "Unassign from customer", + "unassign-edge-title": "Are you sure you want to unassign the edge '{{edgeName}}'?", + "unassign-edge-text": "After the confirmation the edge will be unassigned and won't be accessible by the customer.", + "unassign-edges-title": "Are you sure you want to unassign { count, plural, =1 {1 edge} other {# edges} }?", + "unassign-edges-text": "After the confirmation all selected edges will be unassigned and won't be accessible by the customer.", + "make-public": "Make edge public", + "make-public-edge-title": "Are you sure you want to make the edge '{{edgeName}}' public?", + "make-public-edge-text": "After the confirmation the edge and all its data will be made public and accessible by others.", + "make-private": "Make edge private", + "public": "Public", + "make-private-edge-title": "Are you sure you want to make the edge '{{edgeName}}' private?", + "make-private-edge-text": "After the confirmation the edge and all its data will be made private and won't be accessible by others.", + "import": "Import edge", + "install-connect-instructions": "Install & Connect Instructions", + "install-connect-instructions-edge-created": "Edge created! Check Install & Connect Instructions", + "loading-edge-instructions": "Loading edge instructions...", + "label": "Label", + "load-entity-error": "Failed to load data. Entity has been deleted.", + "assign-new-edge": "Assign new edge", + "unassign-from-edge": "Unassign from edge", + "edge-key": "Edge key", + "copy-edge-key": "Copy Edge key", + "edge-key-copied-message": "Edge key has been copied to clipboard", + "edge-secret": "Edge secret", + "copy-edge-secret": "Copy Edge secret", + "edge-secret-copied-message": "Edge secret has been copied to clipboard", + "manage-assets": "Manage assets", + "manage-devices": "Manage devices", + "manage-entity-views": "Manage entity views", + "manage-dashboards": "Manage dashboards", + "manage-rulechains": "Manage rule chains", + "assets": "Edge assets", + "devices": "Edge devices", + "entity-views": "Edge entity views", + "dashboard": "Edge dashboard", + "dashboards": "Edge Dashboards", + "rulechain-templates": "Rule chain templates", + "edge-rulechain-templates": "Edge rule chain templates", + "converter-templates": "Converter templates", + "edge-converter-templates": "Edge converter templates", + "integration-templates": "Integration templates", + "edge-integration-templates": "Edge integration templates", + "rulechains": "Edge rule chains", + "integrations": "Integrations", + "search": "Search edges", + "selected-edges": "{ count, plural, =1 {1 edge} other {# edges} } selected", + "any-edge": "Any edge", + "no-edge-types-matching": "No edge types matching '{{entitySubtype}}' were found.", + "edge-type-list-empty": "No edge types selected.", + "edge-types": "Edge types", + "enter-edge-type": "Enter edge type", + "deployed": "Deployed", + "pending": "Pending", + "downlinks": "Downlinks", + "no-downlinks-prompt": "No downlinks found", + "sync-process-started-successfully": "Sync process started successfully!", + "missing-related-rule-chains-title": "Edge has missing related rule chain(s)", + "missing-related-rule-chains-text": "Assigned to edge rule chain(s) use rule nodes that forward message(s) to rule chain(s) that are not assigned to this edge.

List of missing rule chain(s):
{{missingRuleChains}}", + "widget-datasource-error": "This widget supports only EDGE entity datasource", + "assign-to-edge": "Assign to edge", + "assign-to-edge-title": "Assign Entity Group(s) to Edge", + "manage-edge-user-groups": "Manage edge user groups", + "manage-edge-asset-groups": "Manage edge asset groups", + "manage-edge-device-groups": "Manage edge device groups", + "manage-edge-entity-view-groups": "Manage edge entity view groups", + "manage-edge-dashboard-groups": "Manage edge dashboard groups", + "manage-edge-rule-chains": "Manage edge rule chains", + "manage-edge-integrations": "Manage edge integrations", + "manage-edge-scheduler-events": "Manage edge scheduler events", + "select-group-to-add": "Select target group to add selected edges", + "select-group-to-move": "Select target group to move selected edges", + "remove-edges-from-group": "Are you sure you want to remove { count, plural, =1 {1 edge} other {# edges} } from group '{entityGroup}'?", + "group": "Group of edges", + "list-of-groups": "{ count, plural, =1 {One edge group} other {List of # edge groups} }", + "group-name-starts-with": "Edge groups whose names start with '{{prefix}}'", + "unassign-entity-group-from-edge-title": "Are you sure you want to unassign the entity group '{{ entityGroupName }}'?", + "unassign-entity-group-from-edge-text": "After the confirmation the entity group will be unassigned and won't be accessible by the edge.", + "unassign-entity-group-from-edge": "Unassign entity group from edge", + "unassign-entity-groups-from-edge-title": "Are you sure you want to unassign {{count}} entity groups?", + "unassign-entity-groups-from-edge-text": "After the confirmation entity groups will be unassigned and won't be accessible by the edge.", + "unassign-entity-groups-from-edge": "Unassign entity groups from edge", + "unassign-scheduler-events-from-edge": "Unassign scheduler events from edge", + "unassign-scheduler-event-from-edge-title": "Are you sure you want to unassign scheduler event '{{schedulerEventName}}'?", + "unassign-scheduler-event-from-edge-text": "After the confirmation the scheduler event will be unassigned and won't be accessible by the edge.", + "unassign-scheduler-events-from-edge-title": "Are you sure you want to unassign { count, plural, =1 {1 scheduler event} other {# scheduler events} }?", + "unassign-scheduler-events-from-edge-text": "After the confirmation all selected scheduler events will be unassigned and won't be accessible by the edge.", + "manage-user-groups": "Manage user groups", + "manage-asset-groups": "Manage asset groups", + "manage-device-groups": "Manage device groups", + "manage-dashboard-groups": "Manage dashboard groups", + "manage-entity-view-groups": "Manage entity view groups", + "manage-scheduler-events": "Manage scheduler events", + "manage-integrations": "Manage integrations", + "assign-scheduler-event-to-edge-title": "Assign Scheduler Events To Edge", + "assign-scheduler-event-to-edge-text": "Please select the scheduler events to assign to the edge", + "assign-entity-groups-to-edge-text": "Please select the entity groups to assign to the edge", + "assign-integration-to-edge-title": "Assign Integrations To Edge", + "assign-integration-to-edge-text": "Please select the integrations to assign to the edge", + "missing-attributes-title": "Edge has missing attribute(s)", + "missing-attributes-text": "Integration(s), assigned to edge, have attribute placeholders that are not available on this edge.

List of missing edge attributes(s):
{{missingEdgeAttributes}}", + "missing-all-related-attributes-title": "Edge(s) have missing attribute(s)", + "missing-all-related-attributes-text": "Edge(s), related to integration, don't have attributes that are present inside integration configuration as attribute placeholders.

List of missing edge attributes(s):
{{missingEdgeAttributes}}", + "quick-overview-widget-header": "{{edgeName}} Quick Overview" + }, + "edge-event": { + "type-dashboard": "Dashboard", + "type-asset": "Asset", + "type-device": "Device", + "type-device-profile": "Device Profile", + "type-asset-profile": "Asset Profile", + "type-entity-view": "Entity View", + "type-alarm": "Alarm", + "type-entity-group": "Entity Group", + "type-rule-chain": "Rule Chain", + "type-rule-chain-metadata": "Rule Chain Metadata", + "type-edge": "Edge", + "type-user": "User", + "type-tenant": "Tenant", + "type-tenant-profile": "Tenant Profile", + "type-customer": "Customer", + "type-relation": "Relation", + "type-widgets-bundle": "Widgets Bundle", + "type-widgets-type": "Widgets Type", + "type-admin-settings": "Admin Settings", + "type-ota-package": "Ota Package", + "type-queue": "Queue", + "type-scheduler-event": "Scheduler Event", + "type-white-labeling": "White Labeling", + "type-login-white-labeling": "Login White Labeling", + "type-custom-translation": "Custom Translation", + "type-role": "Role", + "type-group-permission": "Group Permission", + "type-integration": "Integration", + "type-converter": "Converter", + "action-type-added": "Added", + "action-type-deleted": "Deleted", + "action-type-updated": "Updated", + "action-type-post-attributes": "Post Attributes", + "action-type-attributes-updated": "Attributes Updated", + "action-type-attributes-deleted": "Attributes Deleted", + "action-type-timeseries-updated": "Timeseries Updated", + "action-type-credentials-updated": "Credentials Updated", + "action-type-assigned-to-customer": "Assigned to Customer", + "action-type-unassigned-from-customer": "Unassigned from Customer", + "action-type-relation-add-or-update": "Relation Add or Update", + "action-type-relation-deleted": "Relation Deleted", + "action-type-rpc-call": "RPC Call", + "action-type-alarm-ack": "Alarm Ack", + "action-type-alarm-clear": "Alarm Clear", + "action-type-alarm-assigned": "Alarm Assigned", + "action-type-alarm-unassigned": "Alarm Unassigned", + "action-type-assigned-to-edge": "Assigned to Edge", + "action-type-unassigned-from-edge": "Unassigned from Edge", + "action-type-credentials-request": "Credentials Request", + "action-type-entity-merge-request": "Entity Merge Request", + "action-type-added-to-entity-group": "Added to Entity Group", + "action-type-removed-from-entity-group": "Removed from Entity Group", + "action-type-change-owner": "Change owner" + }, + "error": { + "unable-to-connect": "Nepavyksta prisijungti prie serverio! Patikrinkite interneto ryšį.", + "unhandled-error-code": "Neapdorotos klaidos kodas: {{errorCode}}", + "unknown-error": "Nežinoma klaida" + }, + "entity": { + "entity": "Subjektas", + "entities": "Subjektai", + "entities-count": "Subjektų kiekis", + "alarms-count": "Įspėjimų kiekis", + "aliases": "Subjektų pseudonimai", + "aliases-short": "Pseudonimai", + "entity-alias": "Subjekto pseudonimas", + "unable-delete-entity-alias-title": "Subjekto pseudonimo panaikinti nepavyko", + "unable-delete-entity-alias-text": "Subjekto pseudonimas '{{entityAlias}}' negali būti panaikintas, nes jis naudojamas valdikliuose:
{{widgetsList}}", + "duplicate-alias-error": "Pseudonimas '{{alias}}' dubliuojasi.
Subjektų pseudonimai skydelyje negali kartotis.", + "missing-entity-filter-error": "Trūksta pseudonimo '{{alias}}' filtro.", + "configure-alias": "Konfigūruoti '{{alias}}' pseudonimą", + "alias": "Pseudonimas", + "alias-required": "Subjekto pseudonimas būtinas.", + "remove-alias": "Panaikinti subjekto pseudonimą", + "add-alias": "Pridėti subjekto pseudonimą", + "entity-list": "Subjektų sąrašas", + "entity-type": "Subjekto tipas", + "entity-types": "Subjektų tipai", + "entity-type-list": "Subjektų tipų sąrašas", + "any-entity": "Bet kuris subjektas", + "add-entity-type": "Pridėti subjekto tipą", + "enter-entity-type": "Įveskitesubjekto tipą", + "no-entities-matching": "Subjektų, atitinkančių '{{entity}}' nėra.", + "no-entity-types-matching": "Subjektų tipų, atitinkančių '{{entityType}}' nėra.", + "name-starts-with": "Pavadinimas prasideda", + "help-text": "naudokite simbolį '%' pagal tai, kaip norite ieškoti: '%subjekto_pavadinimo_fragmentas%', '%subjekto_pavadinimo_pabaiga', 'subjekto_pavadinimo_pradžia%'.", + "use-entity-name-filter": "Naudoti filtrą", + "entity-list-empty": "Subjektai nepasirinkti.", + "entity-type-list-required": "Nors vienas subjekto tipas turi būti pasirinktas.", + "entity-name-filter-required": "Subjekto vardo filtras būtinas.", + "entity-name-filter-no-entity-matched": "Subjektų, kurių pavadinimas prasideda '{{entity}}' nėra.", + "all-subtypes": "Visi", + "select-entities": "Pasirinkte subjektus", + "no-aliases-found": "Pseudonimų nėra.", + "no-alias-matching": "'{{alias}}' nėra.", + "create-new-alias": "Sukurti naują!", + "create-new": "Sukurti naują", + "key": "Raktas", + "key-name": "Rakto pavadinimas", + "no-keys-found": "Rakto nėra.", + "no-key-matching": "'{{key}}' nėra.", + "create-new-key": "Sukurti naują!", + "type": "Tipas", + "type-required": "Subjekto tipas būtinas.", + "type-device": "Įrenginys", + "type-devices": "Įrenginiai", + "list-of-devices": "{ count, plural, =1 {Vienas įrenginys} other {# įrenginių sąrašas} }", + "device-name-starts-with": "Įrenginiai, kurių pavadinimas prasideda '{{prefix}}'", + "type-device-profile": "Įrenginio profilis", + "type-device-profiles": "Device profiles", + "clear-selected-profiles": "Clear selected profiles", + "list-of-device-profiles": "{ count, plural, =1 {One device profile} other {List of # device profiles} }", + "device-profile-name-starts-with": "Device profiles whose names start with '{{prefix}}'", + "type-asset-profile": "Asset profile", + "type-asset-profiles": "Asset profiles", + "list-of-asset-profiles": "{ count, plural, =1 {One asset profile} other {List of # asset profiles} }", + "asset-profile-name-starts-with": "Asset profiles whose names start with '{{prefix}}'", + "type-asset": "Turtas", + "type-assets": "Turtas", + "list-of-assets": "{ count, plural, =1 {Turtas} other {# Turto sąrašas} }", + "asset-name-starts-with": "Turtas, kurio pavadinimas prasideda '{{prefix}}'", + "type-entity-view": "Subjekto rodinys", + "type-entity-views": "Subjektų rodinys", + "list-of-entity-views": "{ count, plural, =1 {Vienas subjekto rodinys} other {# Subjektų rodinių sąrašas} }", + "entity-view-name-starts-with": "Subjektų rodiniai, kurių pavadinimai prasideda '{{prefix}}'", + "type-rule": "Taisyklė", + "type-rules": "Taisyklės", + "list-of-rules": "{ count, plural, =1 {Viena taisyklė} other {# Taisyklių sąrašas} }", + "rule-name-starts-with": "Taisyklės, kurių pavadinimai prasideda '{{prefix}}'", + "type-plugin": "Papildinys", + "type-plugins": "Papildiniai", + "list-of-plugins": "{ count, plural, =1 {Vienas papildinys} other {# Papildinių sąrašas} }", + "plugin-name-starts-with": "Papildiniai, kurių pavadinimai prasideda '{{prefix}}'", + "type-tenant": "Valdytojas", + "type-tenants": "valdytojai", + "list-of-tenants": "{ count, plural, =1 {Vienas valdytojas} other {# Valdytojų sąrašas} }", + "tenant-name-starts-with": "Valdytojai, kurių pavadinimai prasideda '{{prefix}}'", + "type-tenant-profile": "Tenant profile", + "type-tenant-profiles": "Tenant profiles", + "list-of-tenant-profiles": "{ count, plural, =1 {One tenant profile} other {List of # tenant profiles} }", + "tenant-profile-name-starts-with": "Tenant profiles whose names start with '{{prefix}}'", + "type-customer": "Klientas", + "type-customers": "Klientai", + "list-of-customers": "{ count, plural, =1 {Vienas klientas} other {# Klientų sąrašas} }", + "customer-name-starts-with": "Klientai, kurių pavadinimai prasideda '{{prefix}}'", + "type-user": "Vartotojas", + "type-users": "Vartotojai", + "list-of-users": "{ count, plural, =1 {Vienas vartotojas} other {# Vartotojų sąrašas} }", + "user-name-starts-with": "Vartotojai, kurių pavadinimai prasideda '{{prefix}}'", + "type-dashboard": "Skydelis", + "type-dashboards": "Skydeliai", + "list-of-dashboards": "{ count, plural, =1 {Vienas skydelis} other {# Skydelių sąrašas} }", + "dashboard-name-starts-with": "Skydeliai, kurių pavadinimai prasideda '{{prefix}}'", + "type-alarm": "Įspėjimas", + "type-alarms": "Įspėjimai", + "list-of-alarms": "{ count, plural, =1 {Vienas įspėjimas} other {# Įspėjimų sąrašas} }", + "alarm-name-starts-with": "Įspėjimai, kurių pavadinimai prasideda '{{prefix}}'", + "type-rulechain": "Taisyklių grandinė", + "type-rulechains": "Taisyklių grandinės", + "list-of-rulechains": "{ count, plural, =1 {Viena taisyklių grandinė} other {# Taisyklių grandinių sąrašas} }", + "rulechain-name-starts-with": "Taisyklių grandinės, kurų pavadinimai prasideda '{{prefix}}'", + "type-scheduler-event": "Tvarkaraščio įvykis", + "type-scheduler-events": "Tvarkaraščio įvykiai", + "list-of-scheduler-events": "{ count, plural, =1 {Vienas tvarkaraščio įvykis} other {# Tvarkaraščio įvykių sąrašas} }", + "scheduler-event-name-starts-with": "Tvarkaraščio įvykiai, kurių pavadinimai prasideda '{{prefix}}'", + "type-blob-entity": "Blob entity", + "type-blob-entities": "Blob entities", + "list-of-blob-entities": "{ count, plural, =1 {One blob entity} other {List of # blob entities} }", + "blob-entity-name-starts-with": "Blob entities whose names start with '{{prefix}}'", + "type-rulenode": "Tasyklė", + "type-rulenodes": "Taisyklės", + "list-of-rulenodes": "{ count, plural, =1 {Viena taisyklė} other {# Taisyklių sąrašas} }", + "rulenode-name-starts-with": "Taisyklės, kurių pavadinimai prasideda '{{prefix}}'", + "type-current-customer": "Dabartinis klientas", + "type-current-tenant": "Dabartinis valdytojas", + "type-current-user": "Dabartinis vartotojas", + "type-current-user-owner": "Dabartinio vartotojo savininkas", + "type-widgets-bundle": "Valdiklių rinkinys", + "type-widgets-bundles": "Valdiklių rinkiniai", + "list-of-widgets-bundles": "{ count, plural, =1 {Vienas valdiklių rinkinys} other {# Valdiklių rinkinių sąrašas} }", + "type-widget-type": "Valdiklio tipas", + "type-widget-types": "Valdiklių tipai", + "list-of-widget-types": "{ count, plural, =1 {Vienas valdiklio tipas} other {# Valdiklių tipų sąrašas} }", + "search": "Subjektų paieška", + "selected-entities": "Pasirinkta { count, plural, =1 {1 subjektas} other {# subjektai} }", + "entity-name": "Subjekto pavadinimas", + "entity-label": "Subjekto etikelė", + "details": "Informacija apie subjektą", + "no-entities-prompt": "Subjektų nėra", + "no-data": "Duomenų atvaizdavimui nėra", + "columns-to-display": "Rodomi stulpeliai", + "type-api-usage-state": "Api Usage State", + "type-entity-group": "Subjektų grupė", + "type-converter": "Duomenų keitiklis", + "type-converters": "Duomenų keitikliai", + "list-of-converters": "{ count, plural, =1 {Vienas duomenų keitiklis} other {# Duomenų keitiklių sąrašas} }", + "converter-name-starts-with": "Duomenų keitikliai, kurių pavadinimai prasideda '{{prefix}}'", + "type-integration": "Integracija", + "type-integrations": "Integracijos", + "list-of-integrations": "{ count, plural, =1 {Viena integracija} other {# Integracijų sąrašas} }", + "integration-name-starts-with": "Integracijos, kurių pavadinimai prasideda '{{prefix}}'", + "type-role": "Rolė", + "type-roles": "Rolės", + "list-of-roles": "{ count, plural, =1 {Viena rolė} other {# rolių sąrašas} }", + "role-name-starts-with": "Rolės, kurių pavadinimai prasideda '{{prefix}}'", + "type-group-permission": "Grupės leidimai", + "type-edge": "Edge", + "type-edges": "Edges", + "list-of-edges": "{ count, plural, =1 {One edge} other {List of # edges} }", + "edge-name-starts-with": "Edges whose names start with '{{prefix}}'", + "type-tb-resource": "Resource", + "type-ota-package": "OTA package", + "type-rpc": "RPC", + "type-queue": "Queue", + "type-notification": "Notification", + "type-notification-rule": "Notification rule", + "type-notification-rules": "Notification rules", + "list-of-notification-rules": "{ count, plural, =1 {One notification rule} other {List of # notification rules} }", + "type-notification-target": "Notification recipient", + "type-notification-targets": "Notification recipients", + "list-of-notification-targets": "{ count, plural, =1 {One notification recipient} other {List of # notification recipients} }", + "type-notification-request": "Notification request", + "type-notification-template": "Notification template", + "type-notification-templates": "Notification templates", + "list-of-notification-templates": "{ count, plural, =1 {One notification template} other {List of # notification templates} }", + "customer-name": "Klientas", + "sub-customer-name": "Priklausantis klientas", + "parent-customer-name": "Pirminis klientas", + "parent-sub-customer-name": "Pirminis klientas", + "include-customer-entities": "Rodyti klientui priklausančius klientus", + "include-sub-customer-entities": "Rodyti priklausančių klientų subjektus", + "groups": "Grupė" + }, + "entity-group": { + "entity-group": "Subjektų grupė", + "details": "Informacija", + "columns": "Stulpeliai", + "add-column": "Pridėti stulpelį", + "column-value": "Reikšmė", + "column-value-required": "Stulpelio reikšmė būtina.", + "column-title": "Pavadinimas", + "default-sort-order": "Numatytoji rūšiavimo tvarka", + "default-sort-order-required": "Numatytoji rūšiavimo tvarka būtina.", + "hide-in-mobile-view": "Nerodyti mobiliąjame režime", + "use-cell-style-function": "Taikyti langelio stiliaus funkciją", + "use-cell-content-function": "Taikyti langelio turinio funkciją", + "edit-column": "Redaguoti stulpelį", + "column-details": "Stulpelio informacija", + "actions": "Veiksmai", + "settings": "Nustatymai", + "search": "Subjektų grupių paieška", + "delete": "Panaikinti subjektų grupę", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "name-max-length": "Pavadinimas turi būti trumpesnis nei 256 simboliai", + "description": "Aprašymas", + "add": "Pridėti subjektų grupę", + "open-entity-group": "Atverti subjektų grupę", + "add-entity-group-text": "Pridėti naują subjektų grupę", + "no-entity-groups-text": "Subjektų grupių nėra", + "entity-group-details": "Informacija apie subjektų grupę", + "selected-entity-groups": "Pasirinkta { count, plural, =1 {1 subjektų grupė} other {# subjektų grupės} }", + "delete-entity-groups": "Panaikinti subjektų grupes", + "delete-entity-group-title": "Ar tikrai norite panaikinti subjektų grupę '{{entityGroupName}}'?", + "delete-entity-group-text": "Būkite dėmesingi, po patvirtinimo, subjektų grupė ir visa su ja susijusi informacija bus pašalinta ir jų atkurti nebegalėsite.", + "delete-entity-groups-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 subjektų grupę} other {# subjektų grupes} }?", + "delete-entity-groups-action-title": "Panaikinti { count, plural, =1 {1 subjektų grupę} other {# subjektų grupes} }", + "delete-entity-groups-text": "Būkite dėmesingi, po patvirtinimo visos subjektų grupės ir su jomis susijusi informacija bus panaikintos ir jų atkurti nebegalėsite.", + "device-groups": "Įrenginių grupės", + "shared-device-groups": "Bendrinamos įrenginių grupės", + "asset-groups": "Turto grupės", + "shared-asset-groups": "Bendrinamos turto grupės", + "customer-groups": "Klientų grupės", + "shared-customer-groups": "Bendrinamos klientų grupės", + "device-group": "Įrenginių grupė", + "asset-group": "Turto grupė", + "user-group": "Vartotojų grupė", + "user-groups": "Vartotojų grupės", + "customer-group": "Klientų grupė", + "entity-view-groups": "Subjektų rodinių grupė", + "shared-entity-view-groups": "Bendrinamos subjektų rodinių grupės", + "entity-view-group": "Subjektų rodinių grupė", + "edge-groups": "Edge groups", + "shared-edge-groups": "Shared edge groups", + "edge-group": "Edge group", + "dashboard-groups": "Skydelių grupės", + "shared-dashboard-groups": "Bendrinamos skydelių grupės", + "dashboard-group": "Skydelių grupė", + "fetch-more": "Gauti daugiau", + "column-type": { + "column-type": "Stulpelio tipas", + "client-attribute": "Kliento atributas", + "shared-attribute": "Bendrinamas atributas", + "server-attribute": "Serverio atributas", + "timeseries": "Telemetrija", + "entity-field": "Subjekto laukas" + }, + "column-type-required": "Stulpelio tipas būtinas.", + "entity-field": { + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "type": "Tipas", + "device_profile": "Device profile", + "asset_profile": "Asset profile", + "assigned_customer": "Priskirtas klientas", + "authority": "Authority", + "first_name": "Vardas", + "last_name": "Pavardė", + "email": "El. paštas", + "title": "Pavadinimas", + "country": "Šalis", + "state": "Apskritis/rajonas", + "city": "Miestas", + "address": "Adresas", + "address2": "Adresas 2", + "zip": "Pašto kodas", + "phone": "Tel, numeris", + "label": "Etiketė" + }, + "sort-order": { + "asc": "Didėjanti", + "desc": "Mažėjanti", + "none": "Nėra" + }, + "details-mode": { + "on-row-click": "Spustelėjus eilutėje", + "on-action-button-click": "Paspaudus informacijos mygtuką", + "disabled": "Išjungta" + }, + "change-owner": "Pakeisti savininką", + "select-target-owner": "Pasirinkite savininką", + "no-owners-matching": "Savinikų atitinkančių '{{owner}}' nėra.", + "target-owner-required": "Savininkas būtinas.", + "confirm-change-owner-title": "Ar tikrai norite pakeisti savininką { count, plural, =1 {1 pasirinktam subjektui} other {# pasirinktiems subjektams} }?", + "confirm-change-owner-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti subjektai iš esamo savininko bus perkelti į naujo savininko grupę 'Visi'.", + "add-to-group": "Pridėti į grupę", + "move-to-group": "Perkelti į grupę", + "select-entity-group": "Pasirinkite subjektų grupę", + "no-entity-groups-matching": "Subjektų grupių, atitinkančių '{{entityGroup}}' nėra.", + "create-new-entity-group": "Sukurti naują!", + "target-entity-group-required": "Subjektų grupė būtina.", + "select-user-group": "Pasirinkite vartotojų grupę", + "no-user-groups-matching": "Vartotojų grupių, atitinkančių '{{entityGroup}}' nėra.", + "target-user-group-required": "Vartotojų grupė būtina.", + "remove-from-group": "Pašalinti iš grupės", + "group-table-title": "Grupių lentelės pavadinimas", + "enable-search": "Leisti subjektų paiešką", + "enable-add": "Leisti pridėti subjektus", + "enable-delete": "Leisti panaikinti subjektus", + "enable-selection": "Leisti subjektų pasirinkimą", + "enable-group-transfer": "Leisti perkėlimus tarp grupių", + "display-pagination": "Rodyti dalinimą puslapiais", + "default-page-size": "Numatytasis puslapio dydis", + "enable-assignment-actions": "Leisti priskirti", + "enable-credentials-management": "Leisti valdyti įgaliojimus", + "enable-login-as-user": "Leisti prisijungti kaip vartotojui", + "enable-users-management": "Leisti valdyti vartotojus", + "enable-customers-management": "Leisti valdyti klientus", + "enable-assets-management": "Leisti valdyti turtą", + "enable-devices-management": "Leisti valdyti įrenginius", + "enable-entity-views-management": "Leisti subjektų rodinių valdymą", + "enable-edges-management": "Enable edges management", + "enable-dashboards-management": "Leisti skydelių valdymą", + "enable-scheduler-events-management": "Leisti tvarkaraščio įvykių valdymą", + "open-details-on": "Atverti subjekto informaciją", + "select-existing": "Pasirinkti egzistuojančią subjektų grupę", + "create-new": "Sukurti naują subjektų grupę", + "new-entity-group-name": "Naujos subjektų grupės pavadinimas", + "entity-group-list": "Subjektų grupių sąrašas", + "entity-group-list-empty": "Nepasirinktos subjektų grupės.", + "name-starts-with": "Subjektų grupės pavadinimas prasideda", + "entity-group-name-filter-required": "Subjektų grupės filtras būtinas.", + "roles": "Rolės", + "permissions": "Leidimai", + "public": "Vieša", + "entity-group-public": "Subjektų grupė yra vieša", + "make-public": "Subjektų grupę padaryti vieša", + "make-private": "Subjektų grupę padaryti privačia", + "make-public-entity-group-title": "Ar tikrai norite subjektų grupę '{{entityGroupName}}' padaryti vieša?", + "make-public-entity-group-text": "Po patvirtinimo subjektų grupė ir visi jai priklausantys subjektai taps vieši ir matomi kitiems vartotojams.", + "make-private-entity-group-title": "Ar tikrai norite subjektų grupę '{{entityGroupName}}' padaryti privačia?", + "make-private-entity-group-text": "Po patvirtinimo, subjektų grupė ir visi jai priklausantys subjektai taps privatūs ir nematomi kitiems vartotojams.", + "share": "Pasdalinti grupe", + "copyId": "Kopijuoti subjektų grupės Id", + "idCopiedMessage": "Subjektų grupės Id nukopijuotas į škarpinę", + "entity-group-name": "Subjektų grupės pavadinimas", + "all-users": "Visi vartotojai", + "owner": "Savininkas", + "owner-required": "Savininkas būtinas.", + "manage-owner-and-groups": "Savininkas ir grupės", + "owner-and-groups": "Savininkas ir grupės" + }, + "entity-field": { + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "type": "Tipas", + "first-name": "Vardas", + "last-name": "Pavardė", + "email": "El. paštas", + "title": "Pavadinimas", + "country": "Šalis", + "state": "Rajonas", + "city": "Miestas", + "address": "Addresas", + "address2": "Addresas 2", + "zip": "Pašto kodas", + "phone": "Telefonas", + "label": "Etikelė", + "configuration": "Konfigūracija", + "schedule": "Grafikas", + "originatorId": "Iniciatoriaus Id", + "originatorType": "Iniciatoriaus tipas" + }, + "entity-view": { + "all": "Visi", + "all-entity-views": "Visi subjektų rodiniai", + "groups": "Grupės", + "shared": "bendrinami", + "entity-view": "Subjektų rodiniai", + "entity-view-required": "Subjeto rodinys būtinas.", + "entity-views": "Subjektų rodiniai", + "management": "Subjektų rodinių valdymas", + "view-entity-views": "Peržiūrėti subjektų rodinius", + "entity-view-alias": "Subjektų rodinio pseudonimas", + "aliases": "Subjektų rodinio pseudonimai", + "no-alias-matching": "'{{alias}}' nerastas.", + "no-aliases-found": "Pseudonimų nėra.", + "no-key-matching": "'{{key}}' nerastas.", + "no-keys-found": "Raktų nėra.", + "create-new-alias": "Sukurti naują!", + "create-new-key": "Sukurti naują!", + "duplicate-alias-error": "'{{alias}}' dubliuojasi.
Įrenginio pseudonimai skydelyje turi būti unikalūs.", + "configure-alias": "Konfigūruoti '{{alias}}' pseudonimą", + "no-entity-views-matching": "Subjektų rodinių, atitinkančių '{{entity}}' nėra.", + "public": "Viešas", + "alias": "Pseudonimas", + "alias-required": "Subjekto rodinio pseudonimas būtinas.", + "remove-alias": "Panaikinti subjekto rodinio pseudonimą", + "add-alias": "Pridėti subjekto rodinio pseudonimą", + "name-starts-with": "Subjekto rodinio pavadinimas prasideda", + "help-text": "Naudokite '%' simbolį pagal tai, kaip norite ieškoti: '%subjekto_rodinio_pavadinimo_fragmentas%', '%subjekto_rodinio_pavadinimo_pabaiga', 'subjekto_rodinio_pavadinimo_pradžia%'.", + "entity-view-list": "Subjektų rodinių sąrašas", + "use-entity-view-name-filter": "Naudoti filtrą", + "entity-view-list-empty": "Subjektai nepasirinkti.", + "entity-view-name-filter-required": "Subjekto rodinio pavadinimo filtras būtinas.", + "entity-view-name-filter-no-entity-view-matched": "Subjektų rodinių, kurių pavadinimai prasideda '{{entityView}}' nėra.", + "add": "Pridėti subjektų rodinį", + "entity-view-public": "Subjektų rodnys yra viešas", + "assign-to-customer": "Priskirti klientui", + "assign-entity-view-to-customer": "Subjektų rodinį (-ius) priskirti klientui", + "assign-entity-view-to-customer-text": "Pasirinkite subjektų rodinius, kuriuos norite priskirti klientui", + "no-entity-views-text": "Subjektų rodinių nėra", + "assign-to-customer-text": "Pasirinkite klientą, kuriam priskirti subjektų rodinį (-ius)", + "entity-view-details": "Informacija apie subjektų rodinį", + "add-entity-view-text": "Pridėti naują subjektų rodinį", + "delete": "Panikinti subjektų rodinį", + "assign-entity-views": "Priskirti subjektų rodinius", + "assign-entity-views-text": "Priskirti { count, plural, =1 {1 subjektų rodinį} other {# subjektų rodinius} } klientui", + "delete-entity-views": "Panaikinti subjektų rodinius", + "make-public": "Subjektų rodinį padaryti viešu", + "make-private": "Subjektų rodinį padaryti privačiu", + "unassign-from-customer": "Atsieti nuo kliento", + "unassign-entity-views": "Atsieti subjektų rodinius", + "unassign-entity-views-action-title": "Atsieti { count, plural, =1 {1 subjektų rodinį} other {# subjektų rodinius} } nuo kliento", + "assign-new-entity-view": "Priskirti naują subjektų rodinį", + "delete-entity-view-title": "Ar tikrai norite panaikinti subjektų rodinį '{{entityViewName}}'?", + "delete-entity-view-text": "Būkite dėmesingi, po patvirtinimo, subjektų rodinys ir visa su juo susijusi informacija bus panaikinta ir jų atkurti nebegalėsite.", + "delete-entity-views-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 subketų rodinį} other {# subjektų rodinius} }?", + "delete-entity-views-action-title": "Panaikinti { count, plural, =1 {1 subjektų rodinį} other {# subjektų rodinius} }", + "delete-entity-views-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti subjektų rodiniai ir visa su jais susijusi informacija bus panaikinta ir jų atkurti nebegalėsite.", + "make-public-entity-view-title": "Ar tikrai norite subjektų rodinį '{{entityViewName}}' padaryti viešu?", + "make-public-entity-view-text": "Po patvirtinimo subjektų rodinys ir visa su juo susijusi informacija taps vieša ir matoma kitiems vartotojams.", + "make-private-entity-view-title": "Ar tikrai norite subjektų rodinį '{{entityViewName}}' padaryti privačiu?", + "make-private-entity-view-text": "Po patvirtinimo subjektų rodinys ir visa su juo susijusi informacija taps privati ir kiti vartotojai jų nebematys.", + "unassign-entity-view-title": "Ar tikrai norite atsieti subjektų rodinį '{{entityViewName}}'?", + "unassign-entity-view-text": "Po patvirtinimo subjektų rodinys bus atsietas ir klientas jo nebematys.", + "unassign-entity-view": "Atsieti subjektų rodinį", + "unassign-entity-views-title": "Ar tikrai norite atsieti { count, plural, =1 {1 subjektų rodinį} other {# subjektų rodinius} }?", + "unassign-entity-views-text": "Po patvirtinimo visi pasirinkti subjektų rodiniai bus atsieti ir klientas jų nebematys.", + "entity-view-type": "Subjektų rodinio tipas", + "entity-view-type-required": "Subjektų rodinio tipas būtinas.", + "select-entity-view-type": "Pasirinkite subjektų rodinio tipą", + "enter-entity-view-type": "Įveskite subjektų rodinio tipą", + "any-entity-view": "Bet kuris subjektų tipas", + "no-entity-view-types-matching": "Subjektų rodinių, atitinkančių '{{entitySubtype}}' nėra.", + "entity-view-type-list-empty": "Subjektų tipai nepasirinkti.", + "entity-view-types": "Subjektų rodinio tipai", + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "type-max-length": "Rodinio tipas negali viršyti 256 simbolių", + "description": "Aprašymas", + "events": "Įvykiai", + "details": "Informacija", + "copyId": "Kopijuoti subjekto rodinio Id", + "idCopiedMessage": "Subjektų rodinio Id nukopjuotas į iškarpinę", + "assignedToCustomer": "Priskirtas klientui", + "unable-entity-view-device-alias-title": "Subjektų rodinio pseudonimo ištrinti nepavyko", + "unable-entity-view-device-alias-text": "Įrenginio pseudonimas '{{entityViewAlias}}' Negali būti ištrintas, nes jis naudojamas valdikliuose:
{{widgetsList}}", + "select-entity-view": "Pasirinkti subjektų rodinį", + "start-date": "Pradžios data", + "start-ts": "Pradžios laikas", + "end-date": "Pabaigos data", + "end-ts": "Pabaigos laikas", + "date-limits": "Datos limitas", + "client-attributes": "Kliento atributai", + "shared-attributes": "Bendrinami atributai", + "server-attributes": "Serverio atributai", + "timeseries": "Telemetrija", + "client-attributes-placeholder": "Kliento atributai", + "shared-attributes-placeholder": "Bendrinami atributai", + "server-attributes-placeholder": "Serverio atributai", + "timeseries-placeholder": "Telemetrija", + "target-entity": "Tikslinis subjektas", + "attributes-propagation": "Atributų paplitimas", + "attributes-propagation-hint": "Subjektų rodinys automatikškai kopijuos nurodytus atributus iš tikslinio subjekto kiekvieną kartą, kai išsaugosite ar atnaujinsite šį rodinį. Norint išsaugoti sistemos spartą, tikslinio subjekto atributai neperkeliami į subjektų rodinį kiekvieną kartą kai jie pasikeičia. Tačiau jūs galite įjungti automatinį atributų reikšmių perdavimą, taisyklių grandinėje sukonfigūravę \"copy to view\" taisyklę ir \"Post attributes\" bei \"Attributes Updated\" žinutes nukreipę į naują taisyklę.", + "timeseries-data": "Temetrijos duomenys", + "timeseries-data-hint": "Sukonfigūruokite tikslinio objekto telemetrijos duomenų raktus, kurie bus pasiekiami objekto rodinyje. Šios telemetrijos duomenys yra tik skaitomi.", + "select-group-to-add": "Pasirinkite grupę į kurią pridėti pažymėtus subjektų rodinius", + "select-group-to-move": "Pasirinkite grupę į kurią perkelti pažymėtus subjektų rodinius", + "remove-entity-views-from-group": "Ar tikrai norite iš '{{entityGroup}}' grupės pašalinti { count, plural, =1 {1 subjektų rodinį} other {# subjektų rodinius} }?", + "group": "Subjektų rodinių grupė", + "list-of-groups": "{ count, plural, =1 {Viena subjektų rodinių grupė} other {# subjektų rodinių grupių sąrašas} }", + "group-name-starts-with": "Subjektų rodinių grupės pavadinimas prasideda '{{prefix}}'", + "search": "Subjektų rodinių paieška", + "selected-entity-views": "Pasirinkta { count, plural, =1 {1 subjektų rodinys} other {# subjektų rodiniai} }", + "assign-entity-view-to-edge": "Assign Entity View(s) To Edge", + "assign-entity-view-to-edge-text": "Please select the entity views to assign to the edge", + "unassign-entity-view-from-edge-title": "Are you sure you want to unassign the entity view '{{entityViewName}}'?", + "unassign-entity-view-from-edge-text": "After the confirmation the entity view will be unassigned and won't be accessible by the edge.", + "unassign-entity-views-from-edge-action-title": "Unassign { count, plural, =1 {1 entity view} other {# entity views} } from edge", + "unassign-entity-view-from-edge": "Unassign entity view", + "unassign-entity-views-from-edge-title": "Are you sure you want to unassign { count, plural, =1 {1 entity view} other {# entity views} }?", + "unassign-entity-views-from-edge-text": "After the confirmation all selected entity views will be unassigned and won't be accessible by the edge." + }, + "event": { + "events": "Įvykiai", + "event-type": "Įvykio tipas", + "events-filter": "Įvykių filtras", + "clean-events": "Įšvalyti įvykius", + "type-error": "Klaida", + "type-lc-event": "Gyvavimo ciklo įvykis", + "type-stats": "Statistika", + "type-debug-converter": "Debug", + "type-debug-integration": "Debug", + "type-debug-rule-node": "Debug", + "type-debug-rule-chain": "Debug", + "no-events-prompt": "Įvykių nėra", + "error": "Klaida", + "alarm": "Įspėjimas", + "event-time": "Įvykio laikas", + "server": "Serveris", + "body": "Body", + "method": "Metodas", + "type": "Tipas", + "in": "Į", + "out": "Iš", + "metadata": "Metadata", + "message": "Message", + "entity": "Entity", + "message-id": "Message Id", + "copy-message-id": "Copy message Id", + "message-type": "Message Type", + "data-type": "Data Type", + "relation-type": "Relation Type", + "data": "Data", + "event": "Event", + "status": "Status", + "success": "Success", + "failed": "Failed", + "messages-processed": "Messages processed", + "max-messages-processed": "Maximum messages processed", + "min-messages-processed": "Minimum messages processed", + "errors-occurred": "Errors occurred", + "max-errors-occurred": "Maximum errors occurred", + "min-errors-occurred": "Minimum errors occurred", + "min-value": "Minimum value is 0.", + "all-events": "All", + "has-error": "Has error", + "entity-id": "Entity Id", + "copy-entity-id": "Copy entity Id", + "entity-type": "Entity type", + "clear-filter": "Clear Filter", + "clear-request-title": "Clear all events", + "clear-request-text": "Are you sure you want to clear all events?", + "started": "Started", + "updated": "Updated", + "stopped": "Stopped" + }, + "extension": { + "extensions": "Extensions", + "selected-extensions": "{ count, plural, =1 {1 extension} other {# extensions} } selected", + "type": "Type", + "key": "Key", + "value": "Value", + "id": "Id", + "extension-id": "Extension id", + "extension-type": "Extension type", + "transformer-json": "JSON *", + "unique-id-required": "Current extension id already exists.", + "delete": "Delete extension", + "add": "Add extension", + "edit": "Edit extension", + "view": "View extension", + "delete-extension-title": "Are you sure you want to delete the extension '{{extensionId}}'?", + "delete-extension-text": "Be careful, after the confirmation the extension and all related data will become unrecoverable.", + "delete-extensions-title": "Are you sure you want to delete { count, plural, =1 {1 extension} other {# extensions} }?", + "delete-extensions-text": "Be careful, after the confirmation all selected extensions will be removed.", + "converters": "Converters", + "converter-id": "Converter id", + "configuration": "Configuration", + "converter-configurations": "Converter configurations", + "token": "Security token", + "add-converter": "Add converter", + "add-config": "Add converter configuration", + "device-name-expression": "Device name expression", + "device-type-expression": "Device type expression", + "custom": "Custom", + "to-double": "To Double", + "transformer": "Transformer", + "json-required": "Transformer json is required.", + "json-parse": "Unable to parse transformer json.", + "attributes": "Attributes", + "add-attribute": "Add attribute", + "add-map": "Add mapping element", + "timeseries": "Timeseries", + "add-timeseries": "Add timeseries", + "field-required": "Field is required", + "brokers": "Brokers", + "add-broker": "Add broker", + "host": "Host", + "port": "Port", + "port-range": "Port should be in a range from 1 to 65535.", + "ssl": "Ssl", + "credentials": "Credentials", + "username": "Username", + "password": "Password", + "retry-interval": "Retry interval in milliseconds", + "sas": "Shared Access Signature", + "anonymous": "Anonymous", + "basic": "Basic", + "pem": "PEM", + "ca-cert": "CA certificate file *", + "private-key": "Private key file *", + "cert": "Certificate file *", + "no-file": "No file selected.", + "drop-file": "Drop a file or click to select a file to upload.", + "mapping": "Mapping", + "topic-filter": "Topic filter", + "converter-type": "Converter type", + "converter-json": "Json", + "json-name-expression": "Device name json expression", + "topic-name-expression": "Device name topic expression", + "json-type-expression": "Device type json expression", + "topic-type-expression": "Device type topic expression", + "attribute-key-expression": "Attribute key expression", + "attr-json-key-expression": "Attribute key json expression", + "attr-topic-key-expression": "Attribute key topic expression", + "request-id-expression": "Request id expression", + "request-id-json-expression": "Request id json expression", + "request-id-topic-expression": "Request id topic expression", + "response-topic-expression": "Response topic expression", + "value-expression": "Value expression", + "topic": "Topic", + "timeout": "Timeout in milliseconds", + "converter-json-required": "Converter json is required.", + "converter-json-parse": "Unable to parse converter json.", + "filter-expression": "Filter expression", + "connect-requests": "Connect requests", + "add-connect-request": "Add connect request", + "disconnect-requests": "Disconnect requests", + "add-disconnect-request": "Add disconnect request", + "attribute-requests": "Attribute requests", + "add-attribute-request": "Add attribute request", + "attribute-updates": "Attribute updates", + "add-attribute-update": "Add attribute update", + "server-side-rpc": "Server side RPC", + "add-server-side-rpc-request": "Add server-side RPC request", + "device-name-filter": "Device name filter", + "attribute-filter": "Attribute filter", + "method-filter": "Method filter", + "request-topic-expression": "Request topic expression", + "response-timeout": "Response timeout in milliseconds", + "topic-expression": "Topic expression", + "client-scope": "Client scope", + "add-device": "Add device", + "opc-server": "Servers", + "opc-add-server": "Add server", + "opc-add-server-prompt": "Please add server", + "opc-application-name": "Application name", + "opc-application-uri": "Application uri", + "opc-scan-period-in-seconds": "Scan period in seconds", + "opc-security": "Security", + "opc-identity": "Identity", + "opc-keystore": "Keystore", + "opc-type": "Type", + "opc-keystore-type": "Type", + "opc-keystore-location": "Location *", + "opc-keystore-password": "Password", + "opc-keystore-alias": "Alias", + "opc-keystore-key-password": "Key password", + "opc-device-node-pattern": "Device node pattern", + "opc-device-name-pattern": "Device name pattern", + "modbus-server": "Servers/slaves", + "modbus-add-server": "Add server/slave", + "modbus-add-server-prompt": "Please add server/slave", + "modbus-transport": "Transport", + "modbus-tcp-reconnect": "Automatically reconnect", + "modbus-rtu-over-tcp": "RTU over TCP", + "modbus-port-name": "Serial port name", + "modbus-encoding": "Encoding", + "modbus-parity": "Parity", + "modbus-baudrate": "Baud rate", + "modbus-databits": "Data bits", + "modbus-stopbits": "Stop bits", + "modbus-databits-range": "Data bits should be in a range from 7 to 8.", + "modbus-stopbits-range": "Stop bits should be in a range from 1 to 2.", + "modbus-unit-id": "Unit ID", + "modbus-unit-id-range": "Unit ID should be in a range from 1 to 247.", + "modbus-device-name": "Device name", + "modbus-poll-period": "Poll period (ms)", + "modbus-attributes-poll-period": "Attributes poll period (ms)", + "modbus-timeseries-poll-period": "Timeseries poll period (ms)", + "modbus-poll-period-range": "Poll period should be positive value.", + "modbus-tag": "Tag", + "modbus-function": "Function", + "modbus-register-address": "Register address", + "modbus-register-address-range": "Register address should be in a range from 0 to 65535.", + "modbus-register-bit-index": "Bit index", + "modbus-register-bit-index-range": "Bit index should be in a range from 0 to 15.", + "modbus-register-count": "Register count", + "modbus-register-count-range": "Register count should be a positive value.", + "modbus-byte-order": "Byte order", + "sync": { + "status": "Status", + "sync": "Sync", + "not-sync": "Not sync", + "last-sync-time": "Last sync time", + "not-available": "Not available" + }, + "export-extensions-configuration": "Export extensions configuration", + "import-extensions-configuration": "Import extensions configuration", + "import-extensions": "Import extensions", + "import-extension": "Import extension", + "export-extension": "Export extension", + "file": "Extensions file", + "invalid-file-error": "Invalid extension file", + "text": "TEXT", + "json": "JSON", + "binary": "BINARY", + "hex": "HEX" + }, + "feature": { + "advanced-features": "Advanced features" + }, + "filter": { + "add": "Add filter", + "edit": "Edit filter", + "name": "Filter name", + "name-required": "Filter name is required.", + "duplicate-filter": "Filter with same name is already exists.", + "filters": "Filters", + "unable-delete-filter-title": "Unable to delete filter", + "unable-delete-filter-text": "Filter '{{filter}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", + "duplicate-filter-error": "Duplicate filter found '{{filter}}'.
Filters must be unique within the dashboard.", + "missing-key-filters-error": "Key filters is missing for filter '{{filter}}'.", + "filter": "Filter", + "editable": "Editable", + "no-filters-found": "No filters found.", + "no-filter-text": "No filter specified", + "add-filter-prompt": "Please add filter", + "no-filter-matching": "'{{filter}}' not found.", + "create-new-filter": "Create a new one!", + "create-new": "Create new", + "filter-required": "Filter is required.", + "operation": { + "operation": "Operation", + "equal": "equal", + "not-equal": "not equal", + "starts-with": "starts with", + "ends-with": "ends with", + "contains": "contains", + "not-contains": "not contains", + "greater": "greater than", + "less": "less than", + "greater-or-equal": "greater or equal", + "less-or-equal": "less or equal", + "and": "and", + "or": "or", + "in": "in", + "not-in": "not in" + }, + "ignore-case": "ignore case", + "value": "Value", + "remove-filter": "Remove filter", + "duplicate-filter-action": "Duplicate filter", + "preview": "Filter preview", + "no-filters": "No filters configured", + "add-filter": "Add filter", + "add-complex-filter": "Add complex filter", + "add-complex": "Add complex", + "complex-filter": "Complex filter", + "edit-complex-filter": "Edit complex filter", + "edit-filter-user-params": "Edit filter predicate user parameters", + "filter-user-params": "Filter predicate user parameters", + "user-parameters": "User parameters", + "display-label": "Label to display", + "autogenerated-label": "Auto generate label", + "order-priority": "Field order priority", + "key-filter": "Key filter", + "key-filters": "Key filters", + "key-name": "Key name", + "key-name-required": "Key name is required.", + "key-type": { + "key-type": "Key type", + "attribute": "Attribute", + "timeseries": "Timeseries", + "entity-field": "Entity field", + "constant": "Constant" + }, + "value-type": { + "value-type": "Value type", + "string": "String", + "numeric": "Numeric", + "boolean": "Boolean", + "date-time": "Datetime" + }, + "value-type-required": "Key value type is required.", + "key-value-type-change-title": "Are you sure you want to change key value type?", + "key-value-type-change-message": "If you confirm new value type all entered key filters will be removed.", + "no-key-filters": "No key filters configured", + "add-key-filter": "Add key filter", + "remove-key-filter": "Remove key filter", + "edit-key-filter": "Edit key filter", + "date": "Date", + "time": "Time", + "current-tenant": "Current tenant", + "current-customer": "Current customer", + "current-user": "Current user", + "current-device": "Current device", + "default-value": "Default value", + "dynamic-source-type": "Dynamic source type", + "dynamic-value": "Dynamic value", + "no-dynamic-value": "No dynamic value", + "source-attribute": "Source attribute", + "switch-to-dynamic-value": "Switch to dynamic value", + "switch-to-default-value": "Switch to default value", + "inherit-owner": "Inherit from owner", + "source-attribute-not-set": "If source attribute isn't set" + }, + "fullscreen": { + "expand": "Rodyti per visą ekraną", + "exit": "Išjungti rodymą per visą ekraną", + "toggle": "Rodyti per visą ekraną", + "fullscreen": "Rodyti per visą ekraną" + }, + "function": { + "function": "Funkcija" + }, + "gateway": { + "add-entry": "Add configuration", + "advanced": "Advanced", + "checking-device-activity": "Checking device activity", + "command": "Docker commands", + "command-copied-message": "Docker command has been copied to clipboard", + "configuration": "Configuration", + "connector-add": "Add new connector", + "connector-enabled": "Enable connector", + "connector-name": "Connector name", + "connector-name-required": "Connector name is required.", + "connector-type": "Connector type", + "connector-type-required": "Connector type is required.", + "connectors": "Connectors", + "connectors-config": "Connectors configuration", + "connectors-table-enabled": "Enabled", + "connectors-table-name": "Name", + "connectors-table-type": "Type", + "connectors-table-status": "Status", + "connectors-table-actions": "Actions", + "connectors-table-key": "Key", + "connectors-table-class": "Class", + "rpc-command-send": "Send", + "rpc-command-result": "Result", + "rpc-command-edit-params": "Edit parameters", + "gateway-configuration": "General Configuration", + "docker-label": "In order to run ThingsBoard IoT gateway in docker with credentials for this device you can use the following commands.", + "create-new-gateway": "Create a new gateway", + "create-new-gateway-text": "Are you sure you want create a new gateway with name: '{{gatewayName}}'?", + "created-time": "Created time", + "configuration-delete-dialog-header": "Configurations will be deleted", + "configuration-delete-dialog-body": "Turning off Remote Configuration is possible only if there is physical access to the Gateway. All previous configurations will be deleted.

\nTo turn off configuration, enter gateway name below", + "configuration-delete-dialog-input": "Gateway name", + "configuration-delete-dialog-input-required": "Gateway name is mandatory", + "configuration-delete-dialog-confirm": "Turn Off", + "delete": "Delete configuration", + "download-tip": "Download configuration file", + "drop-file": "Drop file here or", + "gateway": "Gateway", + "gateway-exists": "Device with same name is already exists.", + "gateway-name": "Gateway name", + "gateway-name-required": "Gateway name is required.", + "gateway-saved": "Gateway configuration successfully saved.", + "grpc": "GRPC", + "grpc-keep-alive-timeout": "Keep alive timeout (in ms)", + "grpc-keep-alive-timeout-required": "Keep alive timeout is required", + "grpc-keep-alive-timeout-min": "Keep alive timeout can not be less then 1", + "grpc-keep-alive-timeout-pattern": "Keep alive timeout is not valid", + "grpc-keep-alive": "Keep alive (in ms)", + "grpc-keep-alive-required": "Keep alive is required", + "grpc-keep-alive-min": "Keep alive can not be less then 1", + "grpc-keep-alive-pattern": "Keep alive is not valid", + "grpc-min-time-between-pings": "Min time between pings (in ms)", + "grpc-min-time-between-pings-required": "Min time between pings is required", + "grpc-min-time-between-pings-min": "Min time between pings can not be less then 1", + "grpc-min-time-between-pings-pattern": "Min time between pings is not valid", + "grpc-min-ping-interval-without-data": "Min ping interval without data (in ms)", + "grpc-min-ping-interval-without-data-required": "Min ping interval without data is required", + "grpc-min-ping-interval-without-data-min": "Min ping interval without data can not be less then 1", + "grpc-min-ping-interval-without-data-pattern": "Min ping interval without data is not valid", + "grpc-max-pings-without-data": "Max pings without data", + "grpc-max-pings-without-data-required": "Max pings without data is required", + "grpc-max-pings-without-data-min": "Max pings without data can not be less then 1", + "grpc-max-pings-without-data-pattern": "Max pings without data is not valid", + "inactivity-check-period-seconds": "Inactivity check period (in sec)", + "inactivity-check-period-seconds-required": "Inactivity check period is required", + "inactivity-check-period-seconds-min": "Inactivity check period can not be less then 1", + "inactivity-check-period-seconds-pattern": "Inactivity check period is not valid", + "inactivity-timeout-seconds": "Inactivity timeout (in sec)", + "inactivity-timeout-seconds-required": "Inactivity timeout is required", + "inactivity-timeout-seconds-min": "Inactivity timeout can not be less then 1", + "inactivity-timeout-seconds-pattern": "Inactivity timeout is not valid", + "json-parse": "Not valid JSON.", + "json-required": "Field cannot be empty.", + "logs": { + "logs": "Logs", + "days": "days", + "hours": "hours", + "minutes": "minutes", + "seconds": "seconds", + "date-format": "Date format", + "date-format-required": "Date format required", + "log-format": "Log format", + "log-type": "Log type", + "log-format-required": "Log format required", + "remote": "Remote logging", + "remote-logs": "Remote logs", + "local": "Local logging", + "level": "Log level", + "file-path": "File path", + "file-path-required": "File path required", + "saving-period": "Log saving period", + "saving-period-min": "Log saving period can not be less then 1", + "saving-period-required": "Log saving period required", + "backup-count": "Backup count", + "backup-count-min": "Backup count can not be less then 1", + "backup-count-required": "Backup count required" + }, + "min-pack-send-delay": "Min pack send delay (in ms)", + "min-pack-send-delay-required": "Min pack send delay is required", + "min-pack-send-delay-min": "Min pack send delay can not be less then 0", + "no-connectors": "No connectors", + "no-data": "No configurations", + "no-gateway-found": "No gateway found.", + "no-gateway-matching": " '{{item}}' not found.", + "path-logs": "Path to log files", + "path-logs-required": "Path is required.", + "permit-without-calls": "Keep alive permit without calls", + "remote": "Remote configuration", + "remote-logging-level": "Logging level", + "remove-entry": "Remove configuration", + "remote-shell": "Remote shell", + "remote-configuration": "Remote Configuration", + "other": "Other", + "save-tip": "Save configuration file", + "security-type": "Security type", + "security-types": { + "access-token": "Access Token", + "username-password": "Username and Password", + "tls": "TLS", + "tls-access-token": "TLS + Access Token", + "tls-private-key": "TLS + Private Key" + }, + "server-port": "Server port", + "statistics": { + "statistic": "Statistic", + "statistics": "Statistics", + "statistic-commands-empty": "No statistics available", + "commands": "Commands", + "send-period": "Statistic send period (in sec)", + "send-period-required": "Statistic send period is required", + "send-period-min": "Statistic send period can not be less then 60", + "send-period-pattern": "Statistic send period is not valid", + "check-connectors-configuration": "Check connectors configuration (in sec)", + "check-connectors-configuration-required": "Check connectors configuration is required", + "check-connectors-configuration-min": "Check connectors configuration can not be less then 1", + "check-connectors-configuration-pattern": "Check connectors configuration is not valid", + "add": "Add command", + "timeout": "Timeout", + "timeout-ms": "Timeout (in ms)", + "timeout-required": "Timeout is required", + "timeout-min": "Timeout can not be less then 1", + "timeout-pattern": "Timeout is not valid", + "attribute-name": "Attribute name", + "attribute-name-required": "Attribute name is required", + "command": "Command", + "command-required": "Command is required", + "remove": "Remove command" + }, + "storage": "Storage", + "storage-max-file-records": "Maximum records in file", + "storage-max-files": "Maximum number of files", + "storage-max-files-min": "Minimum number is 1.", + "storage-max-files-pattern": "Number is not valid.", + "storage-max-files-required": "Number is required.", + "storage-max-records": "Maximum records in storage", + "storage-max-records-min": "Minimum number of records is 1.", + "storage-max-records-pattern": "Number is not valid.", + "storage-max-records-required": "Maximum records is required.", + "storage-read-record-count": "Read record count in storage", + "storage-read-record-count-min": "Minimum number of records is 1.", + "storage-read-record-count-pattern": "Number is not valid.", + "storage-read-record-count-required": "Read record count is required.", + "storage-max-read-record-count": "Max read record count in storage", + "storage-max-read-record-count-min": "Minimum number of records is 1.", + "storage-max-read-record-count-pattern": "Number is not valid.", + "storage-max-read-record-count-required": "Max Read record count is required.", + "storage-data-folder-path": "Data folder path", + "storage-data-folder-path-required": "Data folder path is required.", + "storage-pack-size": "Maximum event pack size", + "storage-pack-size-min": "Minimum number is 1.", + "storage-pack-size-pattern": "Number is not valid.", + "storage-pack-size-required": "Maximum event pack size is required.", + "storage-path": "Storage path", + "storage-path-required": "Storage path is required.", + "storage-type": "Storage type", + "storage-types": { + "file-storage": "File storage", + "memory-storage": "Memory storage", + "sqlite": "SQLITE" + }, + "thingsboard": "ThingsBoard", + "general": "General", + "thingsboard-host": "ThingsBoard host", + "thingsboard-host-required": "Host is required.", + "thingsboard-port": "ThingsBoard port", + "thingsboard-port-max": "Maximum port number is 65535.", + "thingsboard-port-min": "Minimum port number is 1.", + "thingsboard-port-pattern": "Port is not valid.", + "thingsboard-port-required": "Port is required.", + "tidy": "Tidy", + "tidy-tip": "Tidy config JSON", + "title-connectors-json": "Connector {{typeName}} configuration", + "tls-path-ca-certificate": "Path to CA certificate on gateway", + "tls-path-client-certificate": "Path to client certificate on gateway", + "messages-ttl-check-in-hours": "Messages TTL check in hours", + "messages-ttl-check-in-hours-required": "Messages TTL check in hours is required.", + "messages-ttl-check-in-hours-min": "Min number is 1.", + "messages-ttl-check-in-hours-pattern": "Number is not valid.", + "messages-ttl-in-days": "Messages TTL in days", + "messages-ttl-in-days-required": "Messages TTL in days is required.", + "messages-ttl-in-days-min": "Min number is 1.", + "messages-ttl-in-days-pattern": "Number is not valid.", + "mqtt-qos": "QoS", + "mqtt-qos-required": "QoS is required", + "mqtt-qos-range": "QoS values range is from 0 to 1", + "tls-path-private-key": "Path to private key on gateway", + "toggle-fullscreen": "Toggle fullscreen", + "transformer-json-config": "Configuration JSON*", + "update-config": "Add/update configuration JSON", + "hints": { + "remote-configuration": "Enables remote configuration and management of the gateway", + "remote-shell": "Enables remote control of the operating system with the gateway from the Remote Shell widget", + "host": "Hostname or IP address of ThingsBoard server", + "port": "Port of MQTT service on ThingsBoard server", + "token": "Access token for the gateway from ThingsBoard server", + "client-id": "MQTT client id for the gateway form ThingsBoard server", + "username": "MQTT username for the gateway form ThingsBoard server", + "password": "MQTT password for the gateway form ThingsBoard server", + "ca-cert": "Path to CA certificate file", + "date-form": "Date format in log message", + "data-folder": "Path to folder, that will contains data (Relative or Absolute)", + "log-format": "Log message format", + "remote-log": "Enables remote logging and logs reading from the gateway", + "backup-count": "If backup count is > 0, when a rollover is done, no more than backup count files are kept - the oldest ones are deleted", + "storage": "Provides configuration for saving incoming data before it is sent to the platform", + "max-file-count": "Maximum count of file that will be created", + "max-read-count": "Count of messages to get from storage and send to ThingsBoard", + "max-records": "Maximum count of records that will be stored in one file", + "read-record-count": "Count of messages to get from storage and send to ThingsBoard", + "max-records-count": "Maximum count of data in storage before send to ThingsBoard", + "ttl-check-hour": "How often will Gateway check data for obsolescence", + "ttl-messages-day": "Maximum days that storage will save data", + "commands": "Commands for collecting additional statistic", + "attribute": "Statistic telemetry key", + "timeout": "Timeout for command executing", + "command": "The result of the command execution, will be used as the value for telemetry", + "check-device-activity": "Enables monitor the activity of each connected device", + "inactivity-timeout": "Time after whose the gateway will disconnect device", + "inactivity-period": "Periodicity of device activity check", + "minimal-pack-delay": "Delay between sending packs of messages (Decreasing this setting results in increased CPU usage)", + "qos": "Quality of Service in MQTT messaging (0 - at most once, 1 - at least once)", + "server-port": "Network port on which GRPC server will listen for incoming connections.", + "grpc-keep-alive-timeout": "Maximum time the server should wait for a keepalive ping response before considering the connection dead.", + "grpc-keep-alive": "Duration between two successive keepalive ping messages when there is no active RPC call.", + "grpc-min-time-between-pings": "Minimum amount of time the server should wait between sending keepalive ping messages", + "grpc-max-pings-without-data": "Maximum number of keepalive ping messages that the server can send without receiving any data before it considers the connection dead.", + "grpc-min-ping-interval-without-data": "Minimum amount of time the server should wait between sending keepalive ping messages when there is no data being sent or received.", + "permit-without-calls": "Allow server to keep the GRPC connection alive even when there are no active RPC calls." + } + }, + "grid": { + "delete-item-title": "Ar tikrai norite pašalinti šį elementą?", + "delete-item-text": "Būkite dėmesingi, po patvirtinimo šio elemento ir visos su juo susijusios informacijos atkurti nebegalėsite.", + "delete-items-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 elementą} other {# elementus} }?", + "delete-items-action-title": "Panaikinti { count, plural, =1 {1 elementą} other {# elementus} }", + "delete-items-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti elementai ir su jais susijusi informacija bus pašalinti ir jų atkurti nebegalėsite.", + "add-item-text": "Pridėti naują elementą", + "no-items-text": "Elementų nėra", + "item-details": "Informacija apie elementą", + "delete-item": "Panaikinti elementą", + "delete-items": "Panaikinti elmentus", + "scroll-to-top": "Slinkti į viršų" + }, + "help": { + "goto-help-page": "Pagalba", + "show-help": "Pagalba" + }, + "home": { + "home": "Pagrindinis", + "profile": "Profilis", + "logout": "Atsijungti", + "menu": "Meniu", + "avatar": "Paveiksliukas", + "open-user-menu": "Atverti vartoto meniu" + }, + "file-input": { + "browse-file": "Pasirinkti failą", + "browse-files": "Pasirinkti failus" + }, + "image-input": { + "drop-images-or": "Užvilkite paveikslėlį arba", + "drag-and-drop": "Užvilkite", + "or": "arba", + "browse": "Pasirinkite", + "no-images": "Paveikslėlis nepasirinktas", + "images": "paveikslėliai" + }, + "import": { + "no-file": "Nepasirinktas failas", + "drop-file": "Nuvilkite JSON failą arba spustelėkite, kad pasirinktumėte failą, kurį norite įkelti.", + "drop-csv-file": "Nuvilkite CSV failą arba spustelėkite, kad pasirinktumėte failą, kurį norite įkelti.", + "drop-json-file-or": "Nuvilkite JSON failą arba", + "drop-file-csv": "Nuvilkite CSV failą arba spustelėkite, kad pasirinktumėte failą, kurį norite įkelti.", + "drop-file-csv-or": "Nuvilkite CSV failą arba", + "column-value": "Reikšmė", + "column-title": "Pavadinimas", + "column-example": "Duomenų pavyzdys", + "column-key": "Atributo/telemetrijos raktas", + "credentials": "Įgaliojimai", + "csv-delimiter": "CSV skyriklis", + "csv-first-line-header": "Pirmoje eilutėje stulpelių pavadinimai", + "csv-update-data": "Atnaujinti artibutą/telemetriją", + "details": "Informacija", + "import-csv-number-columns-error": "Faile turi būti bent du stulpeliai", + "import-csv-invalid-format-error": "Neteisingas failo formatas. Elutė: '{{line}}'", + "column-type": { + "name": "Pavadinimas", + "type": "Tipas", + "label": "Etiketė", + "column-type": "Stulpelio tipas", + "client-attribute": "Kliento atributas", + "shared-attribute": "Bendrinamas atributas", + "server-attribute": "Serverio atributas", + "timeseries": "Telemetrija", + "entity-field": "Subjekto laukas", + "access-token": "Prieigos raktas", + "x509": "X.509", + "mqtt": { + "client-id": "MQTT client ID", + "user-name": "MQTT user name", + "password": "MQTT password" + }, + "lwm2m": { + "client-endpoint": "LwM2M endpoint client name", + "security-config-mode": "LwM2M security config mode", + "client-identity": "LwM2M client identity", + "client-key": "LwM2M client key", + "client-cert": "LwM2M client public key", + "bootstrap-server-security-mode": "LwM2M bootstrap server security mode", + "bootstrap-server-secret-key": "LwM2M bootstrap server secret key", + "bootstrap-server-public-key-id": "LwM2M bootstrap server public key or id", + "lwm2m-server-security-mode": "LwM2M server security mode", + "lwm2m-server-secret-key": "LwM2M server secret key", + "lwm2m-server-public-key-id": "LwM2M server public key or id" + }, + "snmp": { + "host": "SNMP host", + "port": "SNMP port", + "version": "SNMP version (v1, v2c or v3)", + "community-string": "SNMP community string" + }, + "isgateway": "Is Gateway", + "activity-time-from-gateway-device": "Activity time from gateway device", + "description": "Description", + "edge-license-key": "License Key", + "cloud-endpoint": "Cloud Endpoint", + "routing-key": "Edge key", + "secret": "Edge secret" + }, + "stepper-text": { + "select-file": "Pasirinkite failą", + "configuration": "Importo konfigūracija", + "column-type": "Pasirinkite stulpelių tipus", + "creat-entities": "Sukurti naujus subjektus" + }, + "message": { + "create-entities": "Sukurti {{count}} nauji subjektai.", + "update-entities": "Atnaujinti {{count}} subjektai.", + "error-entities": "Kuriant {{count}} subjektų įvyko klaidų." + } + }, + "integration": { + "integration": "Integration", + "integrations": "Integrations", + "integrations-center": "Integrations center", + "select-integration": "Select integration", + "no-integrations-matching": "No integrations matching '{{entity}}' were found.", + "integration-required": "Integration is required", + "delete": "Delete integration", + "management": "Integrations management", + "add-integration-text": "Add new integration", + "no-integrations-text": "No integrations found", + "data-convertor-name": "{{convertorType}} data converter for {{integrationName}}", + "integration-name": "{{integrationType}} integration", + "selected-integrations": "{ count, plural, =1 {1 integration} other {# integrations} } selected", + "delete-integration-title": "Are you sure you want to delete the integration '{{integrationName}}'?", + "delete-integration-text": "Be careful, after the confirmation the integration and all related data will become unrecoverable.", + "delete-integrations-title": "Are you sure you want to delete { count, plural, =1 {1 integration} other {# integrations} }?", + "delete-integrations-action-title": "Delete { count, plural, =1 {1 integration} other {# integrations} }", + "delete-integrations-text": "Be careful, after the confirmation all selected integrations will be removed and all related data will become unrecoverable.", + "events": "Events", + "enabled": "Enable integration", + "allow-create-devices-or-assets": "Allow create devices or assets", + "all-types": "All integration types", + "add": "Add Integration", + "search": "Search integrations", + "integration-details": "Integration details", + "details": "Details", + "copyId": "Copy integration Id", + "idCopiedMessage": "Integration Id has been copied to clipboard", + "debug-mode": "Debug mode", + "enable-security": "Enable security", + "enable-security-new": "Enable security for automatic token updates", + "headers-filter": "Headers filter", + "header": "Header", + "no-headers-filter": "No headers filter", + "downlink-url": "Downlink URL", + "downlink-url-required": "Downlink URL is required", + "create-loriot-output": "Create Loriot Application output", + "send-downlink": "Send downlink", + "allow-downlink": "Allow downlink", + "server": "Server", + "server-required": "Server is required", + "domain": "Domain", + "app-id": "Application ID", + "app-id-required": "Application ID is required", + "app-token": "Application Access Token", + "app-token-required": "Application Access Token is required", + "email": "Email", + "email-required": "Email is required", + "application-uri": "Application URI", + "as-id": "AS ID", + "as-id-required": "AS ID is required.", + "as-key": "AS Key", + "as-key-required": "AS Key is required.", + "client-id-new": "Client Id", + "client-id-new-required": "Client Id (login) is required (login).", + "client-secret": "Client Secret", + "client-secret-required": "Client Secret (password) is required (password).", + "max-time-diff-in-seconds": "Maximum time difference (seconds)", + "max-time-diff-in-seconds-required": "Maximum time difference is required.", + "created-time": "Created time", + "name": "Name", + "name-required": "Name is required.", + "name-max-length": "Name should be less than 256", + "description": "Description", + "base-url": "Base URL", + "base-url-required": "Base URL is required", + "security-key": "Security key", + "http-endpoint": "HTTP endpoint URL", + "replace-no-content-to-ok": "Replace response status from 'No-Content' to 'OK'", + "copy-http-endpoint-url": "Copy HTTP endpoint URL", + "http-endpoint-url-copied-message": "HTTP endpoint URL has been copied to clipboard", + "host": "Host", + "host-required": "Host is required.", + "host-private": "Host must be in the public network.", + "url-private": "URL must be in the public network.", + "host-type": "Host type", + "host-type-required": "Host type is required.", + "api-version": "Use API v3", + "custom-host": "Custom host", + "custom-host-required": "Custom host is required.", + "port": "Port", + "port-required": "Port is required.", + "port-range": "Port should be in a range from 1 to 65535.", + "connect-timeout": "Connection timeout (sec)", + "connect-timeout-required": "Connection timeout is required.", + "connect-timeout-range": "Connection timeout should be in a range from 1 to 200.", + "client-id": "Client ID", + "client-id-required": "Client ID is required.", + "client-id-hint": "Hint: Optional. Leave empty for auto-generated client ID. Be careful when specifying the Client ID. Majority of the MQTT brokers will not allow multiple connections with the same client ID. To connect to such brokers, your MQTT client ID must be unique.", + "max-bytes-in-message": "Max bytes in message", + "max-bytes-in-message-range": "Max bytes in message should be in a range from 1 to 256000000.", + "device-id": "Device ID", + "device-id-required": "Device ID is required.", + "device-id-range": "Device ID should be in a range from 1 to 65535 characters.", + "device-id-pattern": "Device ID should consist of numbers, upper and lower case letters. [MQTT-3.1.3-5]", + "group-id": "Group ID", + "group-id-required": "Group ID is required.", + "topics": "Topics", + "topics-required": "Topic is required.", + "routing-keys": "Routing keys", + "routing-keys-required": "Routing key is required.", + "queues": "Queues", + "queues-required": "Queue name is required.", + "durable": "Durable", + "exclusive": "Exclusive", + "autoDelete": "Auto delete", + "exchange-name": "Exchange name", + "exchange-name-required": "Exchange name is required.", + "downlink-topic": "Downlink topic", + "connection-timeout": "Connection timeout, ms", + "connection-timeout-min": "Invalid connection timeout value.", + "handshake-timeout": "Handshake timeout, ms", + "handshake-timeout-min": "Invalid handshake timeout value.", + "virtual-host": "Virtual host", + "rabbit-mq-poll-interval": "Poll interval, ms", + "rabbit-mq-poll-interval-min": "Invalid poll interval value.", + "application-server-url": "Application server URL", + "application-server-url-required": "Application server URL is required.", + "application-server-api-token": "Application server API Token", + "application-server-api-token-required": "Application server API Token is required.", + "use-api-four-plus": "Use API for ChirpStack 4+", + "bootstrap-servers": "Bootstrap servers", + "bootstrap-servers-required": "Bootstrap servers is required.", + "poll-interval": "Poll interval", + "poll-interval-required": "Poll interval is required.", + "poll-interval-min-value": "Poll interval value can't be less then 1", + "auto-create-topics": "Auto create topics", + "clean-session": "Clean session", + "enable-ssl": "Enable SSL", + "credentials": "Credentials", + "credentials-type": "Credentials type", + "credentials-type-required": "Credentials type is required.", + "username": "Username", + "username-required": "Username is required.", + "password": "Password", + "password-required": "Password is required.", + "azure-ca-cert": "CA certificate file", + "ca-cert": "CA certificate file", + "private-key": "Private key file", + "private-key-password": "Private key password", + "cert": "Certificate file", + "no-file": "No file selected.", + "drop-file": "Drop a file or click to select a file to upload.", + "drop-file-or": "Drag and drop a file or", + "check-connection": "Check connection", + "check-success": "Connection established.", + "topic-filters": "Topic filters", + "remove-topic-filter": "Remove topic filter", + "add-topic-filter": "Add topic filter", + "add-topic-filter-prompt": "Please add topic filter", + "topic": "Topic", + "mqtt-qos": "QoS", + "mqtt-qos-required": "QoS is required", + "mqtt-qos-range": "QoS values range is from 0 to 2", + "mqtt-qos-at-most-once": "At most once", + "mqtt-qos-at-least-once": "At least once", + "mqtt-qos-exactly-once": "Exactly once", + "downlink-topic-pattern": "Downlink topic pattern", + "downlink-topic-pattern-required": "Downlink topic pattern is required.", + "retained-message": "Retained", + "aws-access-key-id": "AWS access key Id", + "aws-secret-access-key": "AWS secret access key", + "aws-region": "Region", + "aws-iot-endpoint": "AWS IoT Endpoint", + "aws-iot-endpoint-required": "AWS IoT Endpoint is required.", + "aws-iot-credentials": "AWS IoT Credentials", + "aws-sqs-polling-period-in-seconds": "Polling period in seconds", + "aws-sqs-queue-url": "SQS Queue URL", + "aws-sqs-queue-url-required": "SQS Queue URL is required", + "aws-sqs-access-key-id-required": "Access Key Id is required", + "aws-sqs-secret-access-key-required": "Secret Access Key is required", + "application-credentials": "Application Credentials", + "api-key": "API Key", + "api-key-required": "API Key is required.", + "api-key-format": "Invalid API Key format.", + "auth-token": "Authentication Token", + "auth-token-required": "Authentication Token is required.", + "region": "Region", + "region-required": "Region is required.", + "application-id": "Application ID", + "application-id-required": "Application ID is required.", + "access-id": "Access Id", + "access-id-required": "Access Id is required.", + "access-key": "Access Key", + "access-key-required": "Access Key is required.", + "access-key-min-length": "Access Key is too short.", + "access-key-max-length": "Access Key is too long.", + "connection-parameters": "Connection parameters", + "service-bus-namespace-name": "Service Bus Namespace Name", + "service-bus-namespace-name-required": "Service Bus Namespace Name is required.", + "connection-string": "Connection string", + "consumer-group": "Consumer group", + "connection-string-required": "Connection string required!", + "event-hub-name": "Event Hub Name", + "event-hub-name-required": "Event Hub Name is required.", + "event-iot-hub-name-required": "Iot Hub Name is required for downlink", + "sas-key-name": "SAS Key Name", + "sas-key-name-required": "SAS Key Name is required.", + "sas-key": "SAS Key", + "sas-key-required": "SAS Key is required.", + "iot-hub-name": "IoT Hub Name (required for downlink)", + "hostname": "Hostname", + "hostname-required": "Hostname is required", + "integration-clazz": "Integration class", + "integration-clazz-required": "Integration class is required", + "integration-configuration": "Integration JSON configuration", + "metadata": "Metadata", + "type": "Integration type", + "select-integration-type": "Select integration type", + "type-required": "Integration type is required.", + "type-not-found": "Integration type not found", + "uplink-converter": "Uplink data converter", + "uplink-converter-required": "Uplink data converter is required.", + "downlink-converter": "Downlink data converter", + "type-http": "HTTP", + "type-http-description": "Transferring text or multimedia files protocol.", + "type-ocean-connect": "OceanConnect", + "type-ocean-connect-description": "Integration of devices with different capabilities with the IoT platform.", + "type-sigfox": "SigFox", + "type-sigfox-description": "Lightweight protocol to handle small data transmissions.", + "type-thingpark": "ThingPark", + "type-thingpark-description": "Message-oriented transport layer protocol.", + "type-loriot": "Loriot", + "type-loriot-description": "Distributed LoRaWAN infrastructure.", + "type-thingpark-enterprise": "ThingParkEnterprise", + "type-thingpark-enterprise-description": "Messaging platform for the LPWA network.", + "type-tmobile-iot-cdp": "iotcreators.com (T-Mobile – IoT CDP)", + "type-tmobile-iot-cdp-description": "Mobile IoT connectivity service.", + "type-mqtt": "MQTT", + "type-mqtt-description": "Machine to machine network protocol.", + "type-aws-iot": "AWS IoT", + "type-aws-iot-description": "Management, storage, and analytics service.", + "type-aws-sqs": "AWS SQS", + "type-aws-sqs-description": "Fully managed message queuing service.", + "type-aws-kinesis": "AWS Kinesis", + "type-aws-kinesis-description": "Service for real-time video and data streams.", + "type-ibm-watson-iot": "IBM Watson IoT", + "type-ibm-watson-iot-description": "Fully managed, cloud-hosted service.", + "type-ttn": "The Things Stack Community", + "type-ttn-description": "Open and decentralized LoRaWAN network.", + "type-tti": "The Things Stack Industries", + "type-tti-description": "Software to operate private LoRaWAN networks at scale.", + "type-chirpstack": "ChirpStack", + "type-chirpstack-description": "LoRaWAN network configuration server.", + "type-particle": "Particle", + "type-particle-description": "IoT enablement platform-as-a-service.", + "type-azure-event-hub": "Azure Event Hub", + "type-azure-event-hub-description": "Big data streaming platform and event ingestion service.", + "type-azure-iot-hub": "Azure IoT Hub", + "type-azure-iot-hub-description": "Cloud-hosted messaging service.", + "type-opc-ua": "OPC-UA", + "type-opc-ua-description": "Cross-platform for data exchange from sensors to cloud apps.", + "type-custom": "Custom", + "type-coap": "CoAP", + "type-coap-description": "Machine to machine network protocol for constrained devices.", + "type-udp": "UDP", + "type-udp-description": "Message-oriented transport layer protocol.", + "type-tcp": "TCP", + "type-tcp-description": "Connection-oriented protocol.", + "type-kafka": "Kafka", + "type-kafka-description": "Streaming analytics, data integration streaming platform.", + "type-rabbitmq": "RabbitMQ", + "type-rabbitmq-description": "Message broker that supports multiple messaging protocols.", + "type-pubsub": "Pub/Sub", + "type-pubsub-description": "Service for asynchronous messaging between apps.", + "type-apache-pulsar": "Apache Pulsar", + "type-apache-pulsar-description": "Distributed pub-sub messaging and streaming platform.", + "type-tuya": "Tuya", + "type-tuya-description": "Cloud-hosted messaging. device platform.", + "type-azure-service-bus": "Azure Service Bus", + "type-azure-service-bus-description": "Fully managed enterprise message broker with message queues and publish-subscribe topics.", + "opc-ua-application-name": "Application name", + "opc-ua-application-uri": "Application uri", + "opc-ua-scan-period-in-seconds": "Scan period in seconds", + "opc-ua-scan-period-in-seconds-required": "Scan period is required", + "opc-ua-timeout": "Timeout in milliseconds", + "opc-ua-timeout-required": "Timeout is required", + "opc-ua-security": "Security", + "opc-ua-security-required": "Security is required", + "opc-ua-identity": "Identity", + "opc-ua-identity-required": "Identity is required", + "opc-ua-keystore": "Keystore", + "add-opc-ua-keystore-prompt": "Please add keystore file", + "opc-ua-keystore-required": "Keystore is required", + "opc-ua-type": "Type", + "opc-ua-keystore-type": "Type", + "opc-ua-keystore-type-required": "Type is required", + "opc-ua-keystore-location": "Location *", + "opc-ua-keystore-password": "Password", + "opc-ua-keystore-password-required": "Password is required", + "opc-ua-keystore-alias": "Alias", + "opc-ua-keystore-alias-required": "Alias is required", + "opc-ua-keystore-key-password": "Key password", + "opc-ua-keystore-key-password-required": "Key password is required", + "opc-ua-mapping": "Mapping", + "add-opc-ua-mapping-prompt": "Please add mapping", + "opc-ua-mapping-type": "Mapping type", + "opc-ua-mapping-type-required": "Mapping type is required", + "opc-ua-device-node-pattern": "Device Node Pattern", + "opc-ua-device-node-pattern-required": "Device Node Pattern is required", + "opc-ua-namespace": "Namespace", + "opc-ua-add-map": "Add mapping element", + "kinesis-stream-name": "Stream name", + "kinesis-stream-name-required": "Stream name is required", + "kinesis-region": "Region", + "kinesis-region-required": "Region is required", + "kinesis-access-key-id": "Access Key Id", + "kinesis-access-key-id-required": "Access Key Id is required", + "kinesis-secret-access-key": "Secret Access Key", + "kinesis-secret-access-key-required": "Secret Access Key is required", + "kinesis-use-consumers-with-enhanced-fan-out": "Use Consumers with Enhanced Fan-Out", + "kinesis-use-credentials-from-instance-metadata": "Use credentials from the Amazon EC2 Instance Metadata Service", + "kinesis-application-name": "Application name (by default equals Stream name)", + "kinesis-initial-position-in-stream": "Initial position in stream", + "kinesis-initial-position-in-stream-required": "Initial position in stream required", + "kinesis-max-records": "Max records", + "kinesis-max-records-required": "Max records is required", + "kinesis-max-records-length-range": "Max records length should be in a range from 1 to 10000", + "kinesis-request-timeout": "Request timeout in seconds", + "kinesis-request-timeout-required": "Request timeout is required", + "other-properties": "Other properties", + "subscription-tags": "Subscription tags", + "remove-subscription-tag": "Remove subscription tag", + "add-subscription-tag": "Add subscription tag", + "add-subscription-tag-prompt": "Please add subscription tag", + "key": "Key", + "path": "Path", + "required": "Required", + "integration-key": "Integration key", + "copy-integration-key": "Copy integration key", + "integration-key-copied-message": "Integration key has been copied to clipboard", + "integration-secret": "Integration secret", + "copy-integration-secret": "Copy integration secret", + "integration-secret-copied-message": "Integration secret has been copied to clipboard", + "execute-remotely": "Execute remotely", + "remote": "Remote", + "handler-configuration": "Handler Configuration", + "handler-configuration-type": "Handler Type", + "so-broadcast": "Enable broadcast - integration will accepts broadcast address packets", + "so-keepalive-option": "Enable sending of keep-alive messages on connection-oriented sockets", + "so-reuse-addr": "Bind the process to a port", + "tcp-no-delay": "Forces a socket to send the data without buffering (disable Nagle's buffering algorithm)", + "fail-fast": "Thrown exception soon as the decoder notices the length of the frame will exceed max size", + "strip-delimiter": "Strip Delimiter", + "length-field-offset": "Length Field Offset", + "length-field-offset-required": "Length Field Offset is required.", + "length-field-offset-range": "Length Field Offset should be in a range from 0 to 8.", + "length-field-length": "Length Field Length", + "length-field-length-required": "Length Field Length is required.", + "length-field-length-range": "Length Field Length should be in a range from 0 to 8.", + "length-adjustment": "Length Adjustment (the compensation value to add to the value of the length field)", + "length-adjustment-required": "Length Adjustment is required.", + "length-adjustment-range": "Length Adjustment should be in a range from 0 to 8.", + "byte-order": "Byte Order of the length field", + "initial-bytes-to-strip": "Number of first bytes to strip out from the decoded frame", + "initial-bytes-to-strip-required": "Number of first bytes to strip out from the decoded frame is required.", + "initial-bytes-to-strip-range": "Number of first bytes to strip out from the decoded frame should be in a range from 0 to 8.", + "so-backlog-option": "Max number of pending connects on the socket", + "so-backlog-option-required": "The max number of pending connects on the socket is required.", + "so-backlog-option-range": "The max number of pending connects on the socket should be in a range from 1 to 65535.", + "so-rcv-buf": "Size of the buffer for inbound socket (in KB)", + "so-rcv-buf-required": "Size of the buffer for inbound socket (in KB) is required.", + "so-rcv-buf-range": "Size of the buffer for inbound socket (in KB) should be in a range from 1 to 65535.", + "so-snd-buf": "Size of the buffer for outbound socket (in KB)", + "so-snd-buf-required": "Size of the buffer for outbound socket (in KB) is required.", + "so-snd-buf-range": "Size of the buffer for outbound socket (in KB) should be in a range from 1 to 65535.", + "charset-name": "Charset Name", + "charset-name-required": "Charset Name is required.", + "message-separator": "Message Separator", + "message-separator-required": "Message Separator is required.", + "character-sequence": "Character Sequence", + "character-sequence-required": "Character Sequence is required.", + "max-frame-length": "Max Frame Length (in bytes)", + "max-frame-length-required": "Max Frame Length (in bytes) is required.", + "max-frame-length-range": "Max Frame Length (in bytes) should be in a range from 1 to 65535.", + "handler-type": "Handler Type", + "message-size": "Message Size", + "message-size-required": "Message Size is required.", + "service-url": "Service URL", + "service-url-required": "Service URL is required.", + "subscription-name": "Subscription name", + "subscription-name-required": "Subscription name is required.", + "max-num-messages": "Max number of messages", + "max-num-messages-required": "Max number of messages is required.", + "max-num-bytes": "Max number of bytes", + "max-num-bytes-required": "Max number of bytes required.", + "timeout-in-ms": "Timeout in milliseconds", + "timeout-in-ms-required": "Timeout in milliseconds is required.", + "user-id": "User ID", + "user-id-required": "User ID is required.", + "token": "Token", + "token-required": "Token is required.", + "project-id": "Project ID", + "project-id-required": "Project ID is required.", + "subscription-id": "Subscription ID", + "subscription-id-required": "Subscription ID is required.", + "service-account-key": "Service Account key file", + "service-account-key-required": "Service Account key file is required.", + "tcp": { + "system-line-separator": "System Line Separator", + "nul-delimiter": "Nul Delimiter", + "byte-order-little-endian": "Little Endian", + "byte-order-big-endian": "Big Endian" + }, + "cache-size": "Cache Size", + "cache-time-to-live": "Cache time to live in minutes", + "min-cache-size": "Cache size can't be lower 0", + "min-cache-time-to-live": "Cache time to live can't be lower 0", + "max-cache-time-to-live": "Invalid time of cache live, select between 0 and 525600", + "coap-security-mode": "Security mode", + "coap-security-mode-required": "CoAP security mode is required", + "coap-security-mode-no-secure": "NO SECURE", + "coap-security-mode-dtls": "DTLS", + "coap-security-mode-mixed": "MIXED", + "coap-endpoint": "CoAP endpoint URL", + "coap-endpoint-url-copied-message": "CoAP endpoint URL has been copied to clipboard", + "copy-coap-endpoint-url": "Copy CoAP endpoint URL", + "copy-coap-dtls-endpoint-url": "Copy CoAP DTLS endpoint URL", + "coap-dtls-base-url": "DTLS Base URL", + "coap-dtls-base-url-required": "DTLS Base URL is required", + "coap-dtls-endpoint": "CoAP DTLS endpoint URL", + "coap-dtls-endpoint-url-copied-message": "CoAP DTLS endpoint URL has been copied to clipboard", + "unassign-integration-title": "Are you sure you want to unassign the integration '{{integrationName}}'?", + "unassign-integration-from-edge-text": "After the confirmation the integration will be unassigned and won't be accessible by the edge.", + "unassign-integrations-from-edge-title": "Are you sure you want to unassign { count, plural, =1 {1 integration} other {# integrations} }?", + "unassign-integrations-from-edge-text": "After the confirmation all selected integrations will be unassigned and won't be accessible by the edge.", + "unassign-integrations": "Unassign integrations", + "edge-placeholder-hint": "You can use placeholder ${{ATTRIBUTE_KEY}} to substitute integration field with attribute value from specific Edge entity. For example, 'Edge A' has attribute 'baseUrl' that equals to 'http://localhost:9999'. You can set ${{baseUrl}} as one of the integration fields, and it is going to be replaced to 'http://localhost:9999' during the assignment of this integration to 'Edge A'. Additionally, if 'Edge A' attribute 'baseUrl' is going to be updated - integration with new updated value is going to be automatically provisioned to 'Edge A'.", + "status": { + "status": "Status", + "active": "Active", + "disabled": "Disabled", + "failed": "Failed", + "pending": "Pending" + }, + "daily-activity": "Daily activity", + "enable-debug-mode": "Enable debug mode", + "disable-debug-mode": "Disable debug mode", + "advanced-settings": "Advanced settings", + "basic-settings": "Basic settings", + "existing-converter": "Select existing", + "new-converter": "Create new", + "connection": "Connection", + "connected": "Connected", + "not-connected": "Not connected", + "environment": "Environment", + "environment-required": "Environment is required.", + "region-cn": "China", + "region-us": "United States", + "region-eu": "Europe", + "region-in": "India", + "topic-name": "Topic name", + "topic-name-required": "Topic name is required", + "sub-name": "Subscription name", + "sub-name-required": "Subscription name is required", + "downlink-connection-string": "Downlink connection string", + "downlink-connection-string-required": "Downlink connection string is required", + "downlink-topic-name": "Downlink topic name", + "downlink-topic-name-required": "Downlink topic name is required" + }, + "item": { + "selected": "Pasirinkta" + }, + "js-func": { + "no-return-error": "Funkcija privalo grąžinti reikšmę!", + "return-type-mismatch": "Funkcijos grąžinama reikšmė privalo būti '{{type}}' tipo!", + "tidy": "Tidy", + "mini": "Mini" + }, + "key-val": { + "key": "Raktas", + "value": "Reikšmė", + "remove-entry": "Panaiknti įrašą", + "add-entry": "Pridėti įrašą", + "no-data": "Įrašų nėra" + }, + "layout": { + "layout": "Maketas", + "layouts": "Maketai", + "manage": "Valdyti maketus", + "settings": "Maketų nustatymai", + "color": "Spalva", + "main": "Pagrindinis", + "right": "Į dešinę", + "left": "Į kairę", + "select": "Pasirinkti maketą", + "percentage-width": "Procentinis plotis (%)", + "fixed-width": "Fiksuotas plotis (px)", + "left-width": "Kairysis stulpelis (%)", + "right-width": "Dešinysis stulpelis (%)", + "pick-fixed-side": "Fiksuota pusė: ", + "layout-fixed-width": "Fiksuotas plotis (px)", + "value-min-error": "Reikšmė turi būti dedesnė nei {{min}}{{unit}}", + "value-max-error": "Reikšmė turi būti mažesnė nei {{max}}{{unit}}", + "layout-fixed-width-required": "Fiksuotas plotis būtinas", + "right-width-percentage-required": "Dešinės pusės procentas būtinas", + "left-width-percentage-required": "kairės pusės procentas būtinas", + "divider": "Daliklis", + "right-side": "Dešinės pusės maketas", + "left-side": "Kairės pusės maketas" + }, + "legend": { + "direction": "Elementų išdėstymo kryptis", + "position": "Legendos pozicija", + "show-values": "Rodyti reikšmes", + "min-option": "Min", + "max-option": "Max", + "average-option": "Vidurkis", + "total-option": "Viso", + "latest-option": "Naujausi", + "sort-legend": "Legendoje rodyti duomenų raktus", + "show-max": "Rodyti didžiausią reišmę", + "show-min": "Rodyti mažiausią reikšmę", + "show-avg": "Rodyti vidutinę reikšmę", + "show-total": "Rodyti suminę rekšmę", + "show-latest": "Rodyti naujausią reikšmę", + "settings": "Legendos nustatymai", + "min": "Min", + "max": "Max", + "avg": "Vidurkis", + "total": "Viso", + "latest": "Naujausia", + "comparison-time-ago": { + "previousInterval": "(previous interval)", + "customInterval": "(custom interval)", + "days": "(day ago)", + "weeks": "(week ago)", + "months": "(month ago)", + "years": "(year ago)" + } + }, + "login": { + "login": "Prisijungti", + "request-password-reset": "Prašyti atstatyti slaptažodį", + "reset-password": "Atstatyti slaptažodį", + "create-password": "Nustatyti slaptažodį", + "two-factor-authentication": "Dviejų veiksnių autentifikavimas", + "passwords-mismatch-error": "Įvesti slaptažodžiai turi sutapti!", + "password-again": "Slaptažodį įveskite dar kartą", + "sign-in": "Prisijunkite", + "username": "Vartoto vardas (el. paštas)", + "remember-me": "Prisiminti mane", + "forgot-password": "Pamiršote slaptažodį?", + "password-reset": "Slaptažodis atstatytas", + "expired-password-reset-message": "Jūsų slaptažodio galiojimas baigėsi! Nustatykite naują slaptažodį.", + "new-password": "Naujas slaptažodis", + "new-password-again": "Pakartokite naują slaptažodį", + "password-link-sent-message": "Atstatymo nuoroda išsiųsta", + "email": "El. paštas", + "no-account": "Neturi paskyros?", + "create-account": "Sukurti paskyrą", + "login-with": "Prisijungti kaip {{name}}", + "or": "arba", + "error": "Login error", + "verify-your-identity": "Verify your identity", + "select-way-to-verify": "Select a way to verify", + "resend-code": "Resend code", + "resend-code-wait": "Resend code in { time, plural, =1 {1 second} other {# seconds} }", + "try-another-way": "Try another way", + "totp-auth-description": "Please enter the security code from your authenticator app.", + "totp-auth-placeholder": "Code", + "sms-auth-description": "A security code has been sent to your phone at {{contact}}.", + "sms-auth-placeholder": "SMS code", + "email-auth-description": "A security code has been sent to your email address at {{contact}}.", + "email-auth-placeholder": "Email code", + "backup-code-auth-description": "Please enter one of your backup codes.", + "backup-code-auth-placeholder": "Backup code" + }, + "signup": { + "firstname": "Vardas", + "lastname": "Pavardė", + "email": "El. paštas", + "signup": "Registracija", + "create-password": "nustatyti slaptažodį", + "repeat-password": "Pakartokite slaptažodį", + "have-account": "Jau turite paskyrą?", + "signin": "Prisijungti", + "no-captcha-message": "Turite patvirtinti, jog nesate robotas", + "password-length-message": "Jūsų slaptažodis turi būti ne nei 6 simboliai", + "email-verification": "El. pašto adreso patvirtinimas", + "email-verification-message": "El. laiškas su patvirtinimo informacija išsiųstas nurodytu el. pašto adresu.
Sekite laiške pateiktas intrukcijas ir atlikite prisijungimui reikalingus veiksmus.
Pastaba: Jei nematote el. laiško, patikrinkite 'Šlamšto' katalogą arba bandykite dar kartą siųsti laišką paspausdami 'Siųsti dar kartą' mygtuką", + "account-activation-title": "Paskyros aktyvavimas", + "account-activated": "Paskyra aktyvuota!", + "account-activated-text": "Sveikiname!
Jūsų paskyra aktyvuota.", + "resend": "Siųsti dar kartą", + "inactive-user-exists-title": "Neaktyvuotas vartotojas sistemoje jau yra", + "inactive-user-exists-text": "Vartotojas su tokiu el. pašto adresu jau užregistruotas, tačiau šis adresas nepatvirtintas.
Paspauskite 'Siųsti dar kartą' mygtuką jei norite išsiųsti patvirtinimo laišką.", + "activating-account": "Paskyra aktyvuojama...", + "activating-account-text": "Jūsų paskyra aktyvuojama. Palaukite...", + "accept-privacy-policy": "Sutikti su privatumo politika", + "accept": "Sutikti", + "privacy-policy": "Privatumo politika", + "accept-privacy-policy-message": "You must accept our Privacy Policy", + "recaptcha-title": "reCAPTCHA", + "terms-of-use": "Terms of Use", + "accept-terms-of-use-message": "You must accept our Terms Of Use" + }, + "notification": { + "action-button": "Action button", + "action-type": "Action type", + "active": "Active", + "add-notification-recipients-group": "Add notification recipients group", + "add-notification-template": "Add notification template", + "add-recipient": "Add recipient", + "add-recipients": "Add recipients", + "add-rule": "Add rule", + "add-stage": "Add stage", + "add-template": "Add template", + "after": "After", + "alarm-assignment-trigger-settings": "Alarm assignment trigger settings", + "alarm-comment-trigger-settings": "Alarm comment trigger settings", + "alarm-trigger-settings": "Alarm trigger settings", + "all": "All", + "api-feature-hint": "If the field is empty, the trigger will be applied to all api features", + "api-usage-trigger-settings": "API usage trigger settings", + "new-platform-version-trigger-settings": "New platform version trigger settings", + "rate-limits-trigger-settings": "Exceeded rate limits trigger settings", + "at-least-one-should-be-selected": "At least one should be selected", + "basic-settings": "Basic settings", + "button-text": "Button text", + "button-text-required": "Button text is required", + "button-text-max-length": "Button text should be less than or equal to {{ length }} characters", + "compose": "Compose", + "conversation": "Conversation", + "conversation-required": "Conversation is required", + "copy-notification-template": "Copy notification template", + "copy-rule": "Copy rule", + "copy-template": "Copy template", + "create-new": "Create new", + "created": "Created", + "delete-notification-text": "Be careful, after the confirmation the notification will become unrecoverable.", + "delete-notification-title": "Are you sure you want to delete the notification?", + "delete-notifications-text": "Be careful, after the confirmation notifications will become unrecoverable.", + "delete-notifications-title": "Are you sure you want to delete { count, plural, =1 {1 notification} other {# notifications} }?", + "delete-recipient-text": "Be careful, after the confirmation the recipient will become unrecoverable.", + "delete-recipient-title": "Are you sure you want to delete recipient '{{recipientName}}'?", + "delete-recipients-text": "Be careful, after the confirmation recipients will become unrecoverable.", + "delete-recipients-title": "Are you sure you want to delete { count, plural, =1 {1 recipient} other {# recipients} }?", + "delete-request-text": "Be careful, after the confirmation request will become unrecoverable.", + "delete-request-title": "Are you sure you want to delete request?", + "delete-requests-text": "Be careful, after the confirmation requests will become unrecoverable.", + "delete-requests-title": "Are you sure you want to delete { count, plural, =1 {1 request} other {# requests} }?", + "delete-rule-text": "Be careful, after the confirmation rule will become unrecoverable.", + "delete-rule-title": "Are you sure you want to delete rule '{{ruleName}}'?", + "delete-rules-text": "Be careful, after the confirmation rules will become unrecoverable.", + "delete-rules-title": "Are you sure you want to delete { count, plural, =1 {1 rule} other {# rules} }?", + "delete-template-text": "Be careful, after the confirmation template will become unrecoverable.", + "delete-template-title": "Are you sure you want to delete template '{{templateName}}'?", + "delete-templates-text": "Be careful, after the confirmation templates will become unrecoverable.", + "delete-templates-title": "Are you sure you want to delete { count, plural, =1 {1 template} other {# templates} }?", + "deleted": "Deleted", + "delivery-method": { + "delivery-method": "Delivery method", + "email": "Email", + "email-preview": "Email notification preview", + "slack": "Slack", + "slack-preview": "Slack notification preview", + "microsoft-teams": "Microsoft Teams", + "microsoft-teams-preview": "Microsoft Teams notification preview", + "sms": "SMS", + "sms-preview": "SMS notification preview", + "web": "Web", + "web-preview": "Web notification preview" + }, + "delivery-method-not-configure-click": "Delivery method is not configured. Click to setup.", + "delivery-method-not-configure-contact": "Delivery method is not configured. Contact your system administrator.", + "delivery-methods": "Delivery methods", + "description": "Description", + "device-activity-trigger-settings": "Device active trigger settings", + "device-list-rule-hint": "If the field is empty, the trigger will be applied to all devices", + "device-profiles-list-rule-hint": "If the field is empty, the trigger will be applied to all device profiles", + "disabled": "Disabled", + "edit-notification-recipients-group": "Edit notification recipients group", + "edit-notification-template": "Edit notification template", + "edit-rule": "Edit rule", + "edit-template": "Edit template", + "enabled": "Enabled", + "entities-limit-trigger-settings": "Entities limit trigger settings", + "entity-action-trigger-settings": "Entity action trigger settings", + "entity-type": "Entity type", + "escalation-chain": "Escalation chain", + "failed-send": "Sending failures", + "fails": "{ count, plural, =1 {1 fail} other {# fails} }", + "filter": "Filter", + "first-recipient": "First recipient", + "inactive": "Inactive", + "inbox": "Inbox", + "notification-inbox": "Notifications / Inbox", + "input-field-support-templatization": "Input field support templatization.", + "input-fields-support-templatization": "Input fields support templatization.", + "integration-action-trigger-settings": "Integration events trigger settings", + "integration-list-rule-hint": "If the field is empty, the trigger will be applied to all integrations", + "link": "Link", + "link-required": "Link is required", + "link-type": { + "dashboard": "Open dashboard", + "link": "Open URL link" + }, + "loading-notifications": "Loading notifications...", + "management": "Notification management", + "mark-all-as-read": "Mark all as read", + "mark-as-read": "Mark as read", + "message": "Message", + "message-required": "Message is required", + "message-max-length": "Message should be less than or equal to {{ length }} characters", + "name": "Name", + "name-required": "Name is required", + "new-notification": "New notification", + "no-inbox-notification": "No notification found", + "no-notification-request": "No notification request", + "no-notification-templates": "No notification templates found", + "no-notifications-yet": "No notifications yet", + "no-recipients-notification": "No recipients notification", + "no-rule": "No rule configured", + "no-rules-notification": "No rules notification", + "no-severity-found": "No severity found", + "no-severity-matching": "'{{severity}}' not found.", + "no-template-matching": "No resource matching '{{template}}' were found.", + "not-found-slack-recipient": "Slack recipient not found", + "notification": "Notification", + "notification-center": "Notification center", + "notify": "notify", + "notify-again": "Notify again", + "notify-alarm-action": { + "acknowledged": "Alarm acknowledged", + "assigned": "Alarm assigned", + "cleared": "Alarm cleared", + "created": "Alarm created", + "severity-changed": "Alarm severity changed", + "unassigned": "Alarm unassigned" + }, + "notify-on": "Notify on", + "notify-on-comment-update": "Notify on comment update", + "notify-on-required": "Notify on is required", + "notify-on-unassign": "Notify on unassign", + "notify-only-integrations-errors": "Only notify on error", + "notify-only-user-comments": "Notify only user comments", + "only-rule-chain-lifecycle-failures": "Only rule chain lifecycle failures", + "only-rule-node-lifecycle-failures": "Only rule node lifecycle failures", + "platform-users": "Platform users", + "rate-limits": "Rate limits", + "rate-limits-hint": "If the field is empty, the trigger will be applied to all rate limits", + "recipient": "Recipient", + "recipient-group": "Recipient group", + "recipient-type": { + "affected-tenant-administrators": "Affected tenant administrators", + "affected-user": "Affected user", + "affected-user-hint": "Affected user hint", + "all-users": "All users", + "customer-users": "Customer users", + "system-administrators": "System administrators", + "tenant-administrators": "Tenant administrators", + "user-filters": "User filter", + "user-group-list": "User group list", + "user-list": "User list", + "user-role": "User role", + "users-entity-owner": "Users of the entity owner", + "users-entity-owner-hint": "Users of the entity owner hint" + }, + "recipients": "Recipients", + "notification-recipients": "Notifications / Recipients", + "recipients-count": "{ count, plural, =1 {1 recipient} other {# recipients} }", + "recipients-required": "Recipients are required", + "refresh-allow-delivery-method": "Refresh allow delivery method", + "request-search": "Request search", + "request-status": { + "processing": "Processing", + "scheduled": "Scheduled", + "sent": "Sent" + }, + "review": "Review", + "rule": "Rule", + "rule-chain-list-rule-hint": "If the field is empty, the trigger will be applied to all rule chains", + "rule-engine-events-trigger-settings": "Rule engine events trigger settings", + "rule-engine-filter": "Rule engine filter", + "rule-name": "Rule name", + "rule-name-required": "Name is required", + "rule-disable": "Disable notification rule", + "rule-enable": "Enable notification rule", + "rule-node-filter": "Rule node filter", + "rules": "Rules", + "notification-rules": "Notifications / Rules", + "scheduler-later": "Schedule for later", + "search-notification": "Search notifications", + "search-recipients": "Search recipients", + "search-rules": "Search rules", + "search-templates": "Search templates", + "see-documentation": "See documentation", + "selected-notifications": "{ count, plural, =1 {1 notification} other {# notifications} } selected", + "selected-recipients": "{ count, plural, =1 {1 recipient} other {# recipients} } selected", + "selected-requests": "{ count, plural, =1 {1 request} other {# requests} } selected", + "selected-rules": "{ count, plural, =1 {1 rule} other {# rules} } selected", + "selected-template": "{ count, plural, =1 {1 template} other {# templates} } selected", + "send-notification": "Send notification", + "sent": "Sent", + "notification-sent": "Notifications / Sent", + "set-entity-from-notification": "Set entity from notification to dashboard state", + "slack-chanel-type": "Slack channel type", + "slack-chanel-types": { + "direct": "Direct message", + "private-channel": "Private channel", + "public-channel": "Public channel" + }, + "start-from-scratch": "Start from scratch", + "status": "Status", + "stop-escalation-alarm-status-become": "Stop the escalation on the alarm status become:", + "subject": "Subject", + "subject-required": "Subject is required", + "template": "Template", + "template-name": "Template name", + "template-required": "Template is required", + "template-type": { + "alarm": "Alarm", + "alarm-assignment": "Alarm assignment", + "alarm-comment": "Alarm comment", + "api-usage-limit": "API usage limit", + "device-activity": "Device activity", + "entities-limit": "Entities limit", + "entity-action": "Entity action", + "general": "General", + "integration-lifecycle-event": "Integration lifecycle event", + "rule-engine-lifecycle-event": "Rule engine lifecycle event", + "rule-node": "Rule node", + "new-platform-version": "New platform version", + "rate-limits": "Exceeded rate limits" + }, + "templates": "Templates", + "notification-templates": "Notifications / Templates", + "tenant-profiles-list-rule-hint": "If the field is empty, the trigger will be applied to all tenant profiles", + "tenants-list-rule-hint": "If the field is empty, the trigger will be applied to all tenants", + "threshold": "Threshold", + "theme-color": "Theme color", + "time": "Time", + "track-rule-node-events": "Track rule node events", + "trigger": { + "alarm": "Alarm", + "alarm-assignment": "Alarm assignment", + "alarm-comment": "Alarm comment", + "api-usage-limit": "API usage limit", + "device-activity": "Device activity", + "entities-limit": "Entities limit", + "entity-action": "Entity action", + "integration-lifecycle-event": "Integration lifecycle event", + "rule-engine-lifecycle-event": "Rule engine lifecycle event", + "new-platform-version": "New platform version", + "rate-limits": "Exceeded rate limits", + "trigger": "Trigger", + "trigger-required": "Trigger is required" + }, + "type": "Type", + "unread": "Unread", + "updated": "Updated", + "use-template": "Use template", + "view-all": "View all", + "view-notification-recipients-group": "View notification recipients group", + "view-notification-template": "View notification template", + "view-rule": "View rule", + "warning": "Warning", + "webhook-url": "Webhook URL", + "webhook-url-required": "Webhook URL is required", + "channel-name": "Channel name", + "channel-name-required": "Channel name is required", + "settings": { + "notification-settings": "Notification settings", + "reset-all": "Reset all settings", + "reset-all-title": "Are you sure you want to reset form?", + "reset-all-text": "After the confirmation, the settings form will reset to the default value and save.", + "type": "Type", + "enable-all": "Enable all", + "disable-all": "Disable all", + "delivery-not-configured": "Delivery method is not configured" + } + }, + "ota-update": { + "add": "Add package", + "assign-firmware": "Assigned firmware", + "assign-firmware-required": "Assigned firmware is required", + "assign-software": "Assigned software", + "assign-software-required": "Assigned software is required", + "auto-generate-checksum": "Auto-generate checksum", + "cant-applied-group-all": "Can't be applied for group All", + "checksum": "Checksum", + "checksum-hint": "If checksum is empty, it will be generated automatically", + "checksum-algorithm": "Checksum algorithm", + "checksum-copied-message": "Package checksum has been copied to clipboard", + "change-firmware": "Change of the firmware may cause update of { count, plural, =1 {1 device} other {# devices} }.", + "change-software": "Change of the software may cause update of { count, plural, =1 {1 device} other {# devices} }.", + "chose-compatible-device-profile": "The uploaded package will be available only for devices with the chosen profile.", + "chose-firmware-distributed-device": "Choose firmware that will be distributed to the devices", + "chose-software-distributed-device": "Choose software that will be distributed to the devices", + "content-type": "Content type", + "copy-checksum": "Copy checksum", + "copy-direct-url": "Copy direct URL", + "copyId": "Copy package Id", + "copied": "Copied!", + "delete": "Delete package", + "delete-ota-update-text": "Be careful, after the confirmation the OTA update will become unrecoverable.", + "delete-ota-update-title": "Are you sure you want to delete the OTA update '{{title}}'?", + "delete-ota-updates-text": "Be careful, after the confirmation all selected OTA updates will be removed.", + "delete-ota-updates-title": "Are you sure you want to delete { count, plural, =1 {1 OTA update} other {# OTA updates} }?", + "description": "Description", + "direct-url": "Direct URL", + "direct-url-copied-message": "Package direct URL has been copied to clipboard", + "direct-url-required": "Direct URL is required", + "download": "Download package", + "drop-file": "Drop a package file or click to select a file to upload.", + "drop-package-file-or": "Drag and drop a package file or", + "file-name": "File name", + "file-size": "File size", + "file-size-bytes": "File size in bytes", + "idCopiedMessage": "Package Id has been copied to clipboard", + "no-firmware-matching": "No compatible Firmware OTA Update packages matching '{{entity}}' were found.", + "no-firmware-text": "No compatible Firmware OTA Update packages provisioned.", + "no-packages-text": "No packages found", + "no-software-matching": "No compatible Software OTA Update packages matching '{{entity}}' were found.", + "no-software-text": "No compatible Software OTA Update packages provisioned.", + "ota-update": "OTA update", + "ota-update-details": "OTA update details", + "ota-updates": "OTA updates", + "package-type": "Package type", + "packages-repository": "Packages repository", + "search": "Search packages", + "selected-package": "{ count, plural, =1 {1 package} other {# packages} } selected", + "title": "Title", + "title-required": "Title is required.", + "title-max-length": "Title should be less than 256", + "types": { + "firmware": "Firmware", + "software": "Software" + }, + "upload-binary-file": "Upload binary file", + "use-external-url": "Use external URL", + "version": "Version", + "version-required": "Version is required.", + "version-tag": "Version Tag", + "version-tag-hint": "Custom tag should match the package version reported by your device.", + "version-max-length": "Version should be less than 256", + "warning-after-save-no-edit": "Once the package is uploaded, you will not be able to modify title, version, device profile and package type." + }, + "position": { + "top": "Viršus", + "bottom": "Apačia", + "left": "Kairioji pusė", + "right": "Dešinioji pusė" + }, + "profile": { + "profile": "Profilis", + "last-login-time": "Paskutinio prisijungimo laikas", + "change-password": "Pakeisti slaptažodį", + "current-password": "Dabartinis slaptažodis", + "copy-jwt-token": "Copy JWT token", + "jwt-token": "JWT token", + "token-valid-till": "Token is valid till", + "tokenCopiedSuccessMessage": "JWT token has been copied to clipboard", + "tokenCopiedWarnMessage": "JWT token is expired! Please, refresh the page." + }, + "profiles": { + "profiles": "Profiles" + }, + "security": { + "security": "Security", + "general-settings": "General security settings", + "access-token": "Access token", + "access-token-required": "Access token is required", + "clientId": "Client ID", + "clientId-required": "Client ID is required", + "username": "Username", + "username-required": "Username is required", + "ca-cert": "CA certificate", + "2fa": { + "2fa": "Two-factor authentication", + "2fa-description": "Two-factor authentication protects your account from unauthorized access. All you have to do is enter a security code when you log in.", + "authenticate-with": "You can authenticate with:", + "disable-2fa-provider-text": "Disabling {{name}} will make your account less secure", + "disable-2fa-provider-title": "Are you sure you want to disable {{name}}?", + "get-new-code": "Get new code", + "main-2fa-method": "Use as main two-factor authentication method", + "dialog": { + "activation-step-description-email": "The next time you login in, you will be prompted to enter the security code that will be sent to your email address.", + "activation-step-description-sms": "The next time you login in, you will be prompted to enter the security code that will be sent to the phone number.", + "activation-step-description-totp": "The next time you login in, you will need to provide a two-factor authentication code.", + "activation-step-label": "Activation", + "backup-code-description": "Print out the codes so you have them handy when you need to use them to log in to your account. You can use each backup code once.", + "backup-code-warn": "Once you leave this page, these codes cannot be shown again. Store them safely using the options below.", + "download-txt": "Download (txt)", + "email-step-description": "Enter an email to use as your authenticator.", + "email-step-label": "Email", + "enable-email-title": "Enable email authenticator", + "enable-sms-title": "Enable SMS authenticator", + "enable-totp-title": "Enable authenticator app", + "enter-verification-code": "Enter the 6-digit code here", + "get-backup-code-title": "Get backup code", + "next": "Next", + "scan-qr-code": "Scan this QR code with your verification app", + "send-code": "Send code", + "sms-step-description": "Enter a phone number to use as your authenticator.", + "sms-step-label": "Phone Number", + "success": "Success!", + "totp-step-description-install": "You can install apps like Google Authenticator, Authy, or Duo.", + "totp-step-description-open": "Open the authenticator app on your mobile phone.", + "totp-step-label": "Get app", + "verification-code": "6-digit code", + "verification-code-invalid": "Invalid verification code format", + "verification-code-incorrect": "Verification code is incorrect", + "verification-code-many-request": "Too many requests check verification code", + "verification-step-description": "Enter a 6-digit code we just sent to {{address}}", + "verification-step-label": "Verification" + }, + "provider": { + "email": "Email", + "email-description": "Use a security code sent to your email address to authenticate.", + "email-hint": "Authentication codes are sent via email to {{ info }}", + "sms": "SMS", + "sms-description": "Use your phone to authenticate. We'll send you a security code via SMS message when you log in.", + "sms-hint": "Authentication codes are sent by text message to {{ info }}", + "totp": "Authenticator app", + "totp-description": "Use apps like Google Authenticator, Authy, or Duo on your phone to authenticate. It will generate a security code for logging in.", + "totp-hint": "Authenticator app is set up for your account", + "backup_code": "Backup code", + "backup-code-description": "These printable one-time passcodes allow you to sign in when away from your phone, like when you’re traveling.", + "backup-code-hint": "{{ info }} single-use codes are active at this time" + } + }, + "password-requirement": { + "at-least": "At least:", + "character": "{ count, plural, =1 {1 character} other {# characters} }", + "digit": "{ count, plural, =1 {1 digit} other {# digits} }", + "incorrect-password-try-again": "Incorrect password. Try again", + "lowercase-letter": "{ count, plural, =1 {1 lowercase letter} other {# lowercase letters} }", + "new-passwords-not-match": "New password didn't match", + "password-should-not-contain-spaces": "Your password should not contain spaces", + "password-not-meet-requirements": "Password didn't meet requirements", + "password-requirements": "Password requirements", + "password-should-difference": "New password should be different from current", + "special-character": "{ count, plural, =1 {1 special character} other {# special characters} }", + "uppercase-letter": "{ count, plural, =1 {1 uppercase letter} other {# uppercase letters} }" + } + }, + "relation": { + "relations": "Ryšiai", + "direction": "Direction", + "clear-relation-type": "Clear relation type", + "search-direction": { + "FROM": "Iš", + "TO": "Į" + }, + "direction-type": { + "FROM": "iš", + "TO": "į" + }, + "from-relations": "Išeinantys ryšiai", + "to-relations": "įeinantys ryšiai", + "selected-relations": "Pasirinkta { count, plural, =1 {1 ryšys} other {# ryšiai} } selected", + "type": "Tipas", + "to-entity-type": "Į subjekto tipą", + "to-entity-name": "Į subjektą", + "from-entity-type": "iš subjektų tipo", + "from-entity-name": "Iš subjekto", + "to-entity": "į subjektą", + "from-entity": "Iš subjekto", + "delete": "panaikinti ryšį", + "relation-type": "Ryšio tipas", + "relation-type-required": "Ryšio tipas būtinas.", + "relation-type-max-length": "Ryšio tipas negali viršyti 256 simbolių", + "any-relation-type": "Bet kuris tipas", + "add": "pridėti ryšį", + "edit": "Redaguoti ryšį", + "view": "peržiūrėti ryšį", + "delete-to-relation-title": "Ar tikrai norite panaikinti ryšį su '{{entityName}}' subjektu?", + "delete-to-relation-text": "Būkite dėmesingi, po patvirtinimo '{{entityName}}' subjektas neteks ryšio su dabartiniu subjektu.", + "delete-to-relations-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 ryšį} other {# ryšius} }?", + "delete-to-relations-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti ryšiai bus panaikinti ir atitinkami subjektai neteks ryšių su dabartiniu subjektu.", + "delete-from-relation-title": "Ar tikrai norite panaikinti ryšį iš '{{entityName}}' subjekto?", + "delete-from-relation-text": "Būkite dėmesingi, po patvirtinimo, dabartinis subjektas neteks ryšio su '{{entityName}}' subjektu.", + "delete-from-relations-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 ryšį} other {# ryšius} }?", + "delete-from-relations-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti ryšiai bus panaikinti ir dabartinis subjektas neteks ryšių su atitinkamais subjektais.", + "remove-relation-filter": "Panaikinti ryšių filtrą", + "remove-filter": "Panakinti filtrą", + "add-relation-filter": "Pridėti ryšių filtrą", + "any-relation": "Bet kuris ryšys", + "relation-filters": "Ryšių filtrai", + "additional-info": "Papildoma informacija (JSON)", + "invalid-additional-info": "Papildomos JSON informacijos išanalizuoti nepavyko.", + "no-relations-text": "Ryšių nėra" + }, + "resource": { + "add": "Add Resource", + "all-types": "All", + "copyId": "Copy resource Id", + "delete": "Delete resource", + "delete-resource-text": "Be careful, after the confirmation the resource will become unrecoverable.", + "delete-resource-title": "Are you sure you want to delete the resource '{{resourceTitle}}'?", + "delete-resources-action-title": "Delete { count, plural, =1 {1 resource} other {# resources} }", + "delete-resources-text": "Please note that the selected resources, even if they are used in device profiles, will be deleted.", + "delete-resources-title": "Are you sure you want to delete { count, plural, =1 {1 resource} other {# resources} }?", + "download": "Download resource", + "drop-file": "Drop a resource file or click to select a file to upload.", + "drop-resource-file-or": "Drag and drop a resource file or", + "empty": "Resource is empty", + "file-name": "File name", + "idCopiedMessage": "Resource Id has been copied to clipboard", + "no-resource-matching": "No resource matching '{{widgetsBundle}}' were found.", + "no-resource-text": "No resources found", + "open-widgets-bundle": "Open widgets bundle", + "resource": "Resource", + "resource-library-details": "Resource details", + "resource-type": "Resource type", + "resources-library": "Resources library", + "search": "Search resources", + "selected-resources": "{ count, plural, =1 {1 resource} other {# resources} } selected", + "system": "System", + "title": "Title", + "title-required": "Title is required.", + "title-max-length": "Title should be less than 256", + "type": { + "jks": "JKS", + "js-module": "JS module", + "lwm2m-model": "LWM2M model", + "pkcs-12": "PKCS #12" + } + }, + "rulechain": { + "rulechain": "Rule chain", + "rulechain-events": "Rule chain events", + "rulechains": "Rule chains", + "root": "Root", + "delete": "Delete rule chain", + "name": "Name", + "name-required": "Name is required.", + "name-max-length": "Name should be less than 256", + "description": "Description", + "add": "Add Rule Chain", + "set-root": "Make rule chain root", + "set-root-rulechain-title": "Are you sure you want to make the rule chain '{{ruleChainName}}' root?", + "set-root-rulechain-text": "After the confirmation the rule chain will become root and will handle all incoming transport messages.", + "delete-rulechain-title": "Are you sure you want to delete the rule chain '{{ruleChainName}}'?", + "delete-rulechain-text": "Be careful, after the confirmation the rule chain and all related data will become unrecoverable.", + "delete-rulechains-title": "Are you sure you want to delete { count, plural, =1 {1 rule chain} other {# rule chains} }?", + "delete-rulechains-action-title": "Delete { count, plural, =1 {1 rule chain} other {# rule chains} }", + "delete-rulechains-text": "Be careful, after the confirmation all selected rule chains will be removed and all related data will become unrecoverable.", + "add-rulechain-text": "Add new rule chain", + "no-rulechains-text": "No rule chains found", + "rulechain-details": "Rule chain details", + "details": "Details", + "events": "Events", + "system": "System", + "import": "Import rule chain", + "export": "Export rule chain", + "export-failed-error": "Unable to export rule chain: {{error}}", + "create-new-rulechain": "Create new rule chain", + "rulechain-file": "Rule chain file", + "invalid-rulechain-file-error": "Unable to import rule chain: Invalid rule chain data structure.", + "copyId": "Copy rule chain Id", + "idCopiedMessage": "Rule chain Id has been copied to clipboard", + "select-rulechain": "Select rule chain", + "no-rulechains-matching": "No rule chains matching '{{entity}}' were found.", + "rulechain-required": "Rule chain is required", + "management": "Rules management", + "debug-mode": "Debug mode", + "search": "Search rule chains", + "selected-rulechains": "{ count, plural, =1 {1 rule chain} other {# rule chains} } selected", + "open-rulechain": "Open rule chain", + "edge-template-root": "Template Root", + "assign-to-edge": "Assign to Edge", + "edge-rulechain": "Edge rule chain", + "unassign-rulechain-from-edge-text": "After the confirmation the rulechain will be unassigned and won't be accessible by the edge.", + "unassign-rulechains-from-edge-title": "Are you sure you want to unassign { count, plural, =1 {1 rulechain} other {# rulechains} }?", + "unassign-rulechains-from-edge-text": "After the confirmation all selected rulechains will be unassigned and won't be accessible by the edge.", + "assign-rulechain-to-edge-title": "Assign Rule Chain(s) To Edge", + "assign-rulechain-to-edge-text": "Please select the rulechains to assign to the edge", + "set-edge-template-root-rulechain": "Make rule chain as edge template root", + "set-edge-tscheduler-eventemplate-root-rulechain": "Make rule chain as edge template root", + "set-edge-template-root-rulechain-title": "Are you sure you want to make the rule chain '{{ruleChainName}}' edge template root?", + "set-edge-template-root-rulechain-text": "After the confirmation the rule chain will become edge template root and will be root rule chain for a newly created edges.", + "invalid-rulechain-type-error": "Unable to import rule chain: Invalid rule chain type. Expected type is {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Assign rule chain to edge(s) on creation", + "set-auto-assign-to-edge-title": "Are you sure you want to assign the edge rule chain '{{ruleChainName}}' to edge(s) on creation?", + "set-auto-assign-to-edge-text": "After the confirmation the edge rule chain will be automatically assigned to edge(s) on creation.", + "unset-auto-assign-to-edge": "Do not assign rule chain to edge(s) on creation", + "unset-auto-assign-to-edge-title": "Are you sure you do not want to assign the edge rule chain '{{ruleChainName}}' to edge(s) on creation?", + "unset-auto-assign-to-edge-text": "After the confirmation the edge rule chain will no longer be automatically assigned to edge(s) on creation.", + "unassign-rulechain-title": "Are you sure you want to unassign the rulechain '{{ruleChainName}}'?", + "unassign-rulechains": "Unassign rulechains" + }, + "rulenode": { + "rule-node-events": "Rule node events", + "details": "Details", + "events": "Events", + "search": "Search nodes", + "open-node-library": "Open node library", + "close-node-library": "Close node library", + "add": "Add rule node", + "name": "Name", + "name-required": "Name is required.", + "name-max-length": "Name should be less than 256", + "type": "Type", + "rule-node-description": "Rule node description", + "delete": "Delete rule node", + "select-all-objects": "Select all nodes and connections", + "deselect-all-objects": "Deselect all nodes and connections", + "delete-selected-objects": "Delete selected nodes and connections", + "delete-selected": "Delete selected", + "create-nested-rulechain": "Create nested rule chain", + "select-all": "Select all", + "copy-selected": "Copy selected", + "deselect-all": "Deselect all", + "rulenode-details": "Rule node details", + "debug-mode": "Debug mode", + "singleton-mode": "Singleton mode", + "configuration": "Configuration", + "link": "Link", + "link-details": "Rule node link details", + "add-link": "Add link", + "link-label": "Link label", + "link-label-required": "Link label is required.", + "custom-link-label": "Custom link label", + "custom-link-label-required": "Custom link label is required.", + "link-labels": "Link labels", + "link-labels-required": "Link labels is required.", + "no-link-labels-found": "No link labels found", + "no-link-label-matching": "'{{label}}' not found.", + "create-new-link-label": "Create a new one!", + "type-filter": "Filter", + "type-filter-details": "Filter incoming messages with configured conditions", + "type-enrichment": "Enrichment", + "type-enrichment-details": "Add additional information into Message Metadata", + "type-transformation": "Transformation", + "type-transformation-details": "Change Message payload and Metadata", + "type-action": "Action", + "type-action-details": "Perform special action", + "type-analytics": "Analytics", + "type-analytics-details": "Perform analysis of streamed or persisted data", + "type-external": "External", + "type-external-details": "Interacts with external system", + "type-rule-chain": "Rule Chain", + "type-rule-chain-details": "Forwards incoming messages to specified Rule Chain", + "type-flow": "Flow", + "type-flow-details": "Organizes message flow", + "type-input": "Input", + "type-input-details": "Logical input of Rule Chain, forwards incoming messages to next related Rule Node", + "type-unknown": "Unknown", + "type-unknown-details": "Unresolved Rule Node", + "directive-is-not-loaded": "Defined configuration directive '{{directiveName}}' is not available.", + "ui-resources-load-error": "Failed to load configuration ui resources.", + "invalid-target-rulechain": "Unable to resolve target rule chain!", + "test-script-function": "Test script function", + "script-lang-java-script": "Java Script", + "script-lang-tbel": "TBEL", + "message": "Message", + "message-type": "Message type", + "select-message-type": "Select message type", + "message-type-required": "Message type is required", + "metadata": "Metadata", + "metadata-required": "Metadata entries can't be empty.", + "output": "Output", + "test": "Test", + "help": "Help", + "reset-debug-mode": "Reset debug mode in all nodes", + "test-with-this-message": "{{test}} with this message" + }, + "role": { + "role": "Rolė", + "roles": "Rolės", + "management": "Rolių valdymas", + "view-roles": "Peržiūrėti roles", + "no-roles-matching": "Rolių, atitinkančių '{{entity}}' nėra.", + "role-list": "Rolių sąrašas", + "role-list-required": "Rolių sąrašas būtinas", + "add": "Pridėti rolę", + "view": "Peržiūrėti rolę", + "search": "Rolių paieška", + "selected-roles": "Pasirinkta { count, plural, =1 {1 rolė} other {# rolės} }", + "no-roles-text": "Rolių nėra", + "role-details": "Informacija apie rolę", + "add-role-text": "Pridėti naują rolę", + "delete": "Panaikinti rolę", + "delete-roles": "Panaikinti roles", + "delete-role-title": "Ar tikrai norite panaikinti '{{roleName}}' rolę?", + "delete-role-text": "Būkite dėmesingi, po patvirtinimo, rolė ir visa su ja susijusi informacija bus pašalinta.", + "delete-roles-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 rolę} other {# roles} }?", + "delete-roles-action-title": "Panaikinti { count, plural, =1 {1 rolę} other {# roles} }", + "delete-roles-text": "Būkite dėmesingi, po patvirtinimo, visos pasirinktos rolės ir su jomis susijusi informacija bus pašalintos.", + "role-type": "Tipas", + "role-type-required": "Rolės tipas būtinas.", + "select-role-type": "Pasirinkite rolės tipą", + "enter-role-type": "Įveskite rolės tipą", + "any-role": "Bet kuri rolė", + "no-role-types-matching": "Rolių tipų, atitinkančių '{{entitySubtype}}' nėra.", + "role-type-list-empty": "Nepasirinktas rolės tipas.", + "role-types": "Rolių tipais", + "created-time": "Sukųrimo laikas", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "description": "Aprašymas", + "events": "Įvykiai", + "details": "Informacija", + "copyId": "Kopijuoti rolės Id", + "idCopiedMessage": "Rolės Id nukopijuotas į iškarpinę", + "permissions": "Leidimai", + "role-required": "Rolė būtina", + "roles-required": "Rolės būtinos", + "display-type": { + "GENERIC": "Bendroji", + "GROUP": "Grupinė" + } + }, + "group-permission": { + "user-group-roles": "Vartotojų grupės rolės", + "entity-group-permissions": "Subjektų grupės leidimai", + "role-type": "Rolės tipas", + "role-name": "Rolės pavadinimas", + "group-type": "Grupės tipas", + "group-name": "Grupės pavadinimas", + "group-owner": "Grupės savininkas", + "user-group-name": "Vartotojų grupės pavadinimas", + "user-group-owner": "Vartotojų grupės savininkas", + "edit": "Redaguoti leidimus", + "delete": "Naikinti leidimus", + "selected-group-permissions": "Pasirinkta { count, plural, =1 {1 grupinis leidimas} other {# grupiniai leidimai} }", + "delete-group-permission-title": "Ar tikrai norite panaikinti '{{roleName}}' grupinį leidimą?", + "delete-group-permission-text": "Būkite dėmesingi, po patvirtinimo, grupinis leidimas ir visa su juo sisijusi informacija bus pašalinta.", + "delete-group-permission": "Panaikinti grupinį leidima", + "delete-group-permissions-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 grupinį leidimą} other {# grupinius leidimus} }?", + "delete-group-permissions-text": "Būkite dėmesingi, po patvirtinimo, pasirinkti grupiniai leidimai ir visa su jais susijusi informacija bus panaikinta ir atitinkami vartotojai neteks prieigos prie tam tikrų resursų.", + "delete-group-permissions": "Panaikinti grupinius leidimus", + "add-group-permission": "Pridėti grupinį leidimą", + "edit-group-permission": "Redaguoti grupinį leidimą", + "entity-group": "Subjektų grupė", + "user-group": "Vartotojų grupė", + "no-owners-matching": "Savininkų, atitinkančių '{{owner}}' nėra.", + "target-owner-required": "Subjektų grupės savininkas būtinas.", + "target-user-group-owner-required": "Vartotojų grupės savininkas būtinas.", + "no-group-permissions-text": "Grupinių leidimų nėra" + }, + "permission": { + "permissions-required": "Turi būti nurodytas nors vienas leidimas.", + "remove-permission": "Panaikinti leidimą", + "add-permission": "Pridėti leidimą", + "other": "Kita", + "resource": { + "resource": "Resursas", + "select-resource": "Pasirinkite resursą", + "resource-required": "Resursas būtinas", + "no-resources-matching": "Resursų, atitinkančių '{{resource}}' nėra.", + "display-type": { + "ALL": "Visi", + "PROFILE": "Profilis", + "ADMIN_SETTINGS": "Administratoriaus nustatymai", + "ALARM": "Įspėjimai", + "DEVICE": "Įrenginys", + "DEVICE_PROFILE": "Įrenginio profilis", + "ASSET": "Turtas", + "CUSTOMER": "Klientas", + "DASHBOARD": "Skydelis", + "ENTITY_VIEW": "Subjekto rodinys", + "EDGE": "Edge", + "TENANT": "Valdytojas", + "TENANT_PROFILE": "Valdytojo profilis", + "RULE_CHAIN": "Taisyklių grandinė", + "USER": "Vartotojas", + "WIDGETS_BUNDLE": "Valdiklių rinkinys", + "WIDGET_TYPE": "Valdiklio tipas", + "CONVERTER": "Keitiklis", + "INTEGRATION": "Integracija", + "SCHEDULER_EVENT": "Tvarkaraščio įvykis", + "BLOB_ENTITY": "Blob subjektas", + "CUSTOMER_GROUP": "Klientų grupė", + "DEVICE_GROUP": "Įrenginių grupė", + "ASSET_GROUP": "Turto grupė", + "USER_GROUP": "Vartotojų grupė", + "ENTITY_VIEW_GROUP": "Subjektų rodinių grupė", + "DASHBOARD_GROUP": "Skydelių grupė", + "ROLE": "Rolė", + "GROUP_PERMISSION": "Grupinis leidimas", + "WHITE_LABELING": "Tinkinimas", + "AUDIT_LOG": "Audito žurnalas", + "API_USAGE_STATE": "API Usage State", + "TB_RESOURCE": "Resource", + "EDGE_GROUP": "Edge Group", + "OTA_PACKAGE": "Ota package", + "QUEUE": "Queue", + "VERSION_CONTROL": "Version control", + "ASSET_PROFILE": "Asset Profile", + "NOTIFICATION": "Notification" + } + }, + "operation": { + "operation": "Operacija", + "operations": "Operacijos", + "operations-required": "Turi būti nurodyta nors viena operacija.", + "enter-operation": "Įveskite operaciją", + "no-operations-matching": "Operacijų, atitinkančių '{{operation}}' nėra.", + "display-type": { + "ALL": "Visi", + "CREATE": "Sukurti", + "READ": "Skaityti", + "WRITE": "Rašyti", + "DELETE": "Panaikinti", + "ASSIGN_TO_CUSTOMER": "Priskirti klientui", + "UNASSIGN_FROM_CUSTOMER": "Atsieti nuo kliento", + "RPC_CALL": "RPC kvietimas", + "READ_CREDENTIALS": "Skaityti įgaliojimus", + "WRITE_CREDENTIALS": "Rašyti įgaliojimus", + "READ_ATTRIBUTES": "Skaityti atributus", + "WRITE_ATTRIBUTES": "Rašyti atributus", + "READ_TELEMETRY": "Skaityti telemetriją", + "WRITE_TELEMETRY": "Rašyti telemetriją", + "CLAIM_DEVICES": "Patvirtinti įrenginius", + "IMPERSONATE": "Įeiti kaip vartotojui", + "CHANGE_OWNER": "Pakeisti savininką", + "ADD_TO_GROUP": "Įtraukti į grupę", + "REMOVE_FROM_GROUP": "Pašalinti iš grupės", + "SHARE_GROUP": "Bendrinti grupę", + "ASSIGN_TO_TENANT": "Priskirti valdytojui" + } + } + }, + "scheduler": { + "scheduler": "Tvarkaraštis", + "scheduler-event": "Tvarkaraščio įvykis", + "select-scheduler-event": "Pasirinkite tvarkaraščio įvykį", + "no-scheduler-events-matching": "Tvarkaraščio įvykių, atitinkančių '{{entity}}' nėra.", + "scheduler-event-required": "Tvarkaraščio įvykis būtinas", + "management": "Tvarkaraščio valdymas", + "scheduler-events": "Tvarkaraščio įvykiai", + "add-scheduler-event": "Pridėti tvarkaraščio įvykį", + "search-scheduler-events": "Tvarkaraščio įvykių paieška", + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "type": "Tipas", + "created_customer": "Sukūrė klientas", + "edit-scheduler-event": "Redaguoti tvarkaraščio įvykį", + "view-scheduler-event": "Peržiūrėti tvarkaraščio įvykį", + "delete-scheduler-event": "Panaikinti tvarkaraščio įvykį", + "no-scheduler-events": "Tvarkaraščio įvykių nėra", + "selected-scheduler-events": "Pasirinkta { count, plural, =1 {1 tvarkaraščio įvykis} other {# tvarkaraščio įvykiai} }", + "delete-scheduler-event-title": "Ar tikrai norite panaikinti tvarkaraščio įvykį '{{schedulerEventName}}'?", + "delete-scheduler-event-text": "`", + "delete-scheduler-events-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 tvarkaraščio įvykį} other {# tvarkaraščio įvykius} }?", + "delete-scheduler-events-text": "Būkite dėmesingi, po patvirtinimo, pasirinkti tvarkaraščio įvykiai ir visa su jais susijusi informacija bus panaikinta.", + "create": "Sukurti tvarkaraščio įvykį", + "edit": "Redaguoti tvarkaraščio įvykį", + "view": "Peržiūrėti tvarkaraščio įvykį", + "configuration": "Konfigūracija", + "schedule": "Tvarkaraštis", + "start-time": "Pradžios laikas", + "repeat": "Pakartoti", + "repeats": "Pakartojimai", + "daily": "Kasdieną", + "every-n-days": "Kas N dienas", + "every-n-days-text": "Every { days, plural, =1 {day} other {# days} }", + "weekly": "Kas savaitę", + "every-n-weeks": "Kas N savaites", + "every-n-weeks-text": "Every { weeks, plural, =1 {week} other {# weeks} }", + "monthly": "Kas mėnesį", + "yearly": "Kas metus", + "timer": "Nurodytu laiku", + "repeats-required": "Pakartojimai būtini.", + "repeat-on": "Kartoti", + "repeat-every": "Kartoti kiekvieną", + "repeat-every-n-days": "Kartoti kas N dienas", + "repeat-days-required": "Pakartojim dienų skaičius būtinas.", + "invalid-repeat-days-value": "Pakartojimo dienų skaičius turi būti sveikas teigiamas skaičius.", + "repeat-every-n-weeks": "Kartoti kas N savaites", + "repeat-weeks-required": "Pakartojimo savaičių skaičius būtinas.", + "invalid-repeat-weeks-value": "Pakartojimo savaičių skaičius turi būti sveikas teigiamas skaičius.", + "ends-on": "Pabaiga", + "sunday-label": "Sk", + "monday-label": "Pr", + "tuesday-label": "An", + "wednesday-label": "Tr", + "thursday-label": "Kt", + "friday-label": "Pn", + "saturday-label": "Št", + "repeat-on-sunday": "Kartoti sekmadieniais", + "repeat-on-monday": "Kartoti pirmadieniais", + "repeat-on-tuesday": "Kartoti antradieniais", + "repeat-on-wednesday": "Kartoti trečiadieniais", + "repeat-on-thursday": "Kartoti ketvirtadieniais", + "repeat-on-friday": "Kartoti penktadieniais", + "repeat-on-saturday": "Kartoti šeštadieniais", + "event-type": "Įvykio tipas", + "select-event-type": "Pasirinkite įvykio tiipą", + "event-type-required": "Įvykio tipas būtinas.", + "event-type-max-length": "Įvykio tipas negali viršyti 256 simbolių.", + "list-mode": "Sąrašas", + "calendar-mode": "Kalendorius", + "calendar-view-type": "Kalendoriaus peržiūros tipas", + "month": "Mėnuo", + "week": "Savaitė", + "day": "Diena", + "agenda-week": "Savaitinis tvarkaraštis", + "agenda-day": "Dienų tvarkaraštis", + "list-year": "Sąrašas metams", + "list-month": "Sąrašas mėnesiui", + "list-week": "Sąrašas savaitei", + "list-day": "Sąrašas dienai", + "today": "Šiandien", + "navigate-before": "Ankstesnis", + "navigate-next": "Sekantis", + "starting-from": "Pradėti nuo", + "until": "iki", + "on": "į", + "sunday": "Sekmadienis", + "monday": "Pirmadienis", + "tuesday": "Antradienis", + "wednesday": "Trečiadienis", + "thursday": "Ketvirtadienis", + "friday": "Penktadienis", + "saturday": "Šesštadienis", + "originator": "Iniciatorius", + "single-entity": "Vienas subjektas", + "group-of-entities": "Subjektų grupė", + "entities-group-owner": "Subjektų grupės savininkas", + "single-device": "Vienas įrenginys", + "group-of-devices": "Įrenginių grupė", + "devices-group-owner": "Įrenginių grupės savininkas", + "message-body": "Pranešimo tekstas", + "target": "Tikslas", + "rpc-method": "Method", + "rpc-method-required": "Method is required", + "rpc-method-white-space": "White space is not allowed.", + "rpc-params": "Params", + "select-dashboard-state": "Pasirinkite skydelio būseną", + "hours": "Valandos", + "minutes": "Minutės", + "seconds": "Sekundės", + "time-interval-required": "Laiko intervalas būtinas", + "time-unit-required": "Laiko vienetas būtinas", + "every-hour": "kas { count, plural, =1 {valandą} other {# valandas} }", + "every-minute": "kas { count, plural, =1 {minutę} other {# minutes} }", + "every-second": "kas { count, plural, =1 {sekundę} other {# sekundes} }", + "invalid-time": "Neteisingas laikas" + }, + "report": { + "report-config": "Ataskaitos konfigūracija", + "email-config": "Elektroninio pašto konfigūracija", + "dashboard-state-param": "Skydelio būsenos parametro reikšmė", + "base-url": "Pagrindinis URL", + "base-url-required": "Pagrindinis URL būtinas.", + "use-dashboard-timewindow": "Naudoti skydelio laiko langą", + "timewindow": "Laiko langas", + "name-pattern": "Ataskaitos pavadinimo šablonas", + "name-pattern-required": "Ataskaitos pavadinimo šablonas būtinas", + "type": "Ataskaitos tipas", + "use-current-user-credentials": "Naudoti dabartinio vartotojo įgaliojimus", + "customer-user-credentials": "Kliento vartotojo įgaliojimai", + "customer-user-credentials-required": "Kliento vartotojo įgaliojimai būtini", + "generate-test-report": "Generuoti bandomąją ataskaitą", + "send-email": "Siųsti laišką", + "from": "Nuo", + "from-required": "Būtina nurodyti nuo ko siunčiamas laiškas.", + "to": "Kam", + "to-required": "Būtina nurodyti kam siunčiamas laiškas.", + "cc": "Cc", + "bcc": "Bcc", + "subject": "Tema", + "subject-required": "Būtina nurodyti laiško temą.", + "body": "Tekstas", + "body-required": "Būtina įrašyti laiško tekstą." + }, + "blob-entity": { + "blob-entity": "Blob subjektas", + "select-blob-entity": "Pasirinkite blob subjektą", + "no-blob-entities-matching": "Blob subjektų, atitinkančių '{{entity}}' nėra.", + "blob-entity-required": "Blob subjektas būtinas", + "files": "Failai", + "search": "Failų paieška", + "clear-search": "Išvalyti paiešką", + "no-blob-entities-prompt": "Failų nėra", + "report": "Ataskaita", + "created-time": "Sukūrimo laikas", + "name": "Pavadinimas", + "type": "Tipas", + "created_customer": "Skūrė klientas", + "selected-blob-entities": "Pasirinkta{ count, plural, =1 {1 failas} other {# failai} }", + "download-blob-entity": "Atsisiųsti failą", + "delete-blob-entity": "Ištrinti failą", + "delete-blob-entity-title": "Ar tikrai norite ištrinti '{{blobEntityName}}' failą?", + "delete-blob-entity-text": "Būkite dėmesingi, po patvirtinimo failas bus ištrintas.", + "delete-blob-entities-title": "Artikrai norite ištrinti { count, plural, =1 {1 failą} other {# failus} }?", + "delete-blob-entities-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti failai ir su jais susijusi informacija bus ištrinta." + }, + "timezone": { + "timezone": "Laiko juosta", + "select-timezone": "Pasirinkti laiko juostą", + "no-timezones-matching": "Laiko juostų, atitinkančių '{{timezone}}' nėra.", + "timezone-required": "Laiko juosta būtina.", + "browser-time": "Browser Time" + }, + "queue": { + "queue-name": "Queue", + "no-queues-found": "No queues found.", + "no-queues-matching": "No queues matching '{{queue}}' were found.", + "select-name": "Select queue name", + "name": "Name", + "name-required": "Queue name is required!", + "name-unique": "Queue name is not unique!", + "name-pattern": "Queue name contains a character other than ASCII alphanumerics, '.', '_' and '-'!", + "queue-required": "Queue is required!", + "topic-required": "Queue topic is required!", + "poll-interval-required": "Poll interval is required!", + "poll-interval-min-value": "Poll interval value can't be less then 1", + "partitions-required": "Partitions is required!", + "partitions-min-value": "Partitions value can't be less then 1", + "pack-processing-timeout-required": "Processing timeout is required", + "pack-processing-timeout-min-value": "Processing timeout value can't be less then 1", + "batch-size-required": "Batch size is required!", + "batch-size-min-value": "Batch size value can't be less then 1", + "retries-required": "Retries is required!", + "retries-min-value": "Retries value can't be negative", + "failure-percentage-required": "Failure percentage is required!", + "failure-percentage-min-value": "Failure percentage value can't be less then 0", + "failure-percentage-max-value": "Failure percentage value can't be more then 100", + "pause-between-retries-required": "Pause between retries is required!", + "pause-between-retries-min-value": "Pause between retries value can't be less then 1", + "max-pause-between-retries-required": "Max pause between retries is required!", + "max-pause-between-retries-min-value": "Max pause between retries value can't be less then 1", + "submit-strategy-type-required": "Submit strategy type is required!", + "processing-strategy-type-required": "Processing strategy type is required!", + "queues": "Queues", + "selected-queues": "{ count, plural, =1 {1 queue} other {# queues} } selected", + "delete-queue-title": "Are you sure you want to delete the queue '{{queueName}}'?", + "delete-queues-title": "Are you sure you want to delete { count, plural, =1 {1 queue} other {# queues} }?", + "delete-queue-text": "Be careful, after the confirmation the queue and all related data will become unrecoverable.", + "delete-queues-text": "After the confirmation all selected queues will be deleted and won't be accessible.", + "search": "Search queue", + "add": "Add queue", + "details": "Queue details", + "topic": "Topic", + "submit-settings": "Submit settings", + "submit-strategy": "Strategy type *", + "grouping-parameter": "Grouping parameter", + "processing-settings": "Retries processing settings", + "processing-strategy": "Processing type *", + "retries-settings": "Retries settings", + "polling-settings": "Polling settings", + "batch-processing": "Batch processing", + "poll-interval": "Poll interval", + "partitions": "Partitions", + "immediate-processing": "Immediate processing", + "consumer-per-partition": "Send message poll for each consumer", + "consumer-per-partition-hint": "Enable separate consumer(s) per each partition", + "processing-timeout": "Processing within, ms", + "batch-size": "Batch size", + "retries": "Number of retries (0 – unlimited)", + "failure-percentage": "Percentage of failure messages for skipping retries", + "pause-between-retries": "Retry within, sec", + "max-pause-between-retries": "Additional retry within, sec", + "delete": "Delete queue", + "copyId": "Copy queue Id", + "idCopiedMessage": "Queue Id has been copied to clipboard", + "description": "Description", + "description-hint": "This text will be displayed in the Queue description instead of the selected strategy", + "alt-description": "Submit Strategy: {{submitStrategy}}, Processing Strategy: {{processingStrategy}}", + "custom-properties": "Custom properties", + "custom-properties-hint": "Custom queue (topic) creation properties, e.g. 'retention.ms:604800000;retention.bytes:1048576000'", + "strategies": { + "sequential-by-originator-label": "Sequential by originator", + "sequential-by-originator-hint": "New message for e.g. device A is not submitted until previous message for device A is acknowledged", + "sequential-by-tenant-label": "Sequential by tenant", + "sequential-by-tenant-hint": "New message for e.g tenant A is not submitted until previous message for tenant A is acknowledged", + "sequential-label": "Sequential", + "sequential-hint": "New message is not submitted until previous message is acknowledged", + "burst-label": "Burst", + "burst-hint": "All messages are submitted to the rule chains in the order they arrive", + "batch-label": "Batch", + "batch-hint": "New batch is not submitted until previous batch is acknowledged", + "skip-all-failures-label": "Skip all failures", + "skip-all-failures-hint": "Ignore all failures", + "skip-all-failures-and-timeouts-label": "Skip all failures and timeouts", + "skip-all-failures-and-timeouts-hint": "Ignore all failures and timeouts", + "retry-all-label": "Retry all", + "retry-all-hint": "Retry all messages from processing pack", + "retry-failed-label": "Retry failed", + "retry-failed-hint": "Retry all failed messages from processing pack", + "retry-timeout-label": "Retry timeout", + "retry-timeout-hint": "Retry all timed-out messages from processing pack", + "retry-failed-and-timeout-label": "Retry failed and timeout", + "retry-failed-and-timeout-hint": "Retry all failed and timed-out messages from processing pack" + } + }, + "server-error": { + "general": "General server error", + "authentication": "Authentication error", + "jwt-token-expired": "JWT token expired", + "tenant-trial-expired": "Tenant trial expired", + "credentials-expired": "Credentials expired", + "permission-denied": "Permission denied", + "invalid-arguments": "Invalid arguments", + "bad-request-params": "Bad request params", + "item-not-found": "Item not found", + "too-many-requests": "Too many requests", + "too-many-updates": "Too many updates" + }, + "tenant": { + "tenant": "Valdytojas", + "tenants": "Valdytojai", + "management": "Valdytojų valdymas", + "add": "Pridėti valdytoją", + "admins": "Administratoriai", + "manage-tenant-admins": "Valdyti valdytojo administratorius", + "delete": "Ištrinti valdytoją", + "add-tenant-text": "Pridėti naują valdytoją", + "no-tenants-text": "Valdytojų nėra", + "tenant-details": "Informacija apie valdytoją", + "title-max-length": "Pavadinimas negali viršyti 256 simbolių", + "delete-tenant-title": "Ar tikrai norite ištrinti valdytoją '{{tenantTitle}}'?", + "delete-tenant-text": "Būkite dėmesingi, po patvirtinimo valdytojas ir visa su juo susijusi informacija bus pašalinta.", + "delete-tenants-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 valdytoją} other {# valdytojus} }?", + "delete-tenants-action-title": "Pašalinti { count, plural, =1 {1 valdytoją} other {# valdytojus} }", + "delete-tenants-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti valdytojai ir su jais susijusi informacija bus pašalinta.", + "title": "Pavadinimas", + "title-required": "Pavadinimas būtinas.", + "description": "Aprašymas", + "details": "Informacija", + "events": "Įvykiai", + "copyId": "Kopijuoti valdytojo Id", + "idCopiedMessage": "Valdytojo Id nukopijuotas į iškarpinę", + "select-tenant": "Pasirinkti valdytoją", + "no-tenants-matching": "Valdytojų, atitinkančių '{{entity}}' nėra.", + "tenant-required": "Valdytojas būtinas", + "allow-white-labeling": "Leisti personalizavimą", + "allow-customer-white-labeling": "Leisti kliento personalizavimą", + "search": "Valdytojų paieška", + "selected-tenants": "Pasirinkta { count, plural, =1 {1 valdytojas} other {# valdutojai} }", + "isolated-tb-rule-engine": "Use isolated ThingsBoard Rule Engine queues", + "isolated-tb-rule-engine-details": "Each tenant will have dedicated Rule Engine queues" + }, + "tenant-profile": { + "tenant-profile": "Valdytojų profilis", + "tenant-profiles": "Valdytojų profiliai", + "add": "Pridėti valdytojų profilį", + "add-profile": "Pridėti profilį", + "edit": "Redaguoti valdytojų profilį", + "tenant-profile-details": "Informacija apie valdytojų profilį", + "no-tenant-profiles-text": "Valdytojų profilių nėra", + "name-max-length": "Pavadinimas negali viršyti 256 simbolių", + "search": "Valdytojų profilių paieška", + "selected-tenant-profiles": "Pasirinkta { count, plural, =1 {1 valdytojų profilis} other {# valdytojų profiliai} }", + "no-tenant-profiles-matching": "Valdytojų profilių, atitinkančių '{{entity}}' nėra.", + "tenant-profile-required": "Valdytojų profilis būtinas", + "idCopiedMessage": "Valdytojų profilio Id nukopijuotas į iškarpinę", + "set-default": "Valdytojų profilį nustatyti kaip pagrndinį", + "delete": "Panaikinti valdytojų profilį", + "copyId": "Kopijuoti valdytojų profilio Id", + "name": "Pavadinimas", + "name-required": "Pavadinimas būtinas.", + "data": "Profilio duomenys", + "profile-configuration": "Profilio konfigūracija", + "description": "Aprašymas", + "default": "Pagrindinis", + "delete-tenant-profile-title": "Ar tikrai norite panaikinti valdytojų profilį '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Būkite dėmesingi, po patvirtinimo, valdytojų profilis ir visa su juo susijusi informacija bus pašalinta.", + "delete-tenant-profiles-title": "Ar tikrai norite pašalinti { count, plural, =1 {1 valdytojų profilį} other {# valdytojų profilius} }?", + "delete-tenant-profiles-text": "Būkite dėmesingi, po patvirtinimo, visi pasirinkti valdytojų profiliai ir su jais susijusi informacija bus pašalinta.", + "set-default-tenant-profile-title": "Ar tikrai norite valdytojų profilį '{{tenantProfileName}}' nustatyti kaip pagrindinį?", + "set-default-tenant-profile-text": "Po patvirtinimo, valdytojų profilis bus nustatytas kaip pagrinfinis ir bus priskiriamas naujiems valdytojams, kuriems valdytojų profilis nenurodytas.", + "no-tenant-profiles-found": "Valdytojų profilių nėra.", + "create-new-tenant-profile": "Sukurti naują!", + "create-tenant-profile": "Sukuri naują valdytojo profilį", + "import": "Importuoti valdytojo profilį", + "export": "Eksportuoti valdytojo profilį", + "export-failed-error": "Valdytojo profilio importuoti nepavyko: {{error}}", + "tenant-profile-file": "Valdytojo profilio failas", + "invalid-tenant-profile-file-error": "Valdytojo profilio importuoti nepavyko: Neteisinga valdytojo profilio duomenų struktūra.", + "advanced-settings": "Pažangūs nustatymai", + "entities": "Subjektai", + "rule-engine": "Rule Engine", + "time-to-live": "Time-to-live", + "alarms-and-notifications": "Alarms and notifications", + "ota-files-in-bytes": "OTA files in bytes", + "ws-title": "WS", + "unlimited": "(0 - unlimited)", + "maximum-devices": "Devices maximum number", + "maximum-devices-required": "Devices maximum number is required.", + "maximum-devices-range": "Devices maximum number can't be negative", + "maximum-assets": "Assets maximum number", + "maximum-assets-required": "Assets maximum number is required.", + "maximum-assets-range": "Assets maximum number can't be negative", + "maximum-customers": "Customers maximum number", + "maximum-customers-required": "Customers maximum number is required.", + "maximum-customers-range": "Customers maximum number can't be negative", + "maximum-users": "Users maximum number", + "maximum-users-required": "Users maximum number is required.", + "maximum-users-range": "Users maximum number can't be negative", + "maximum-dashboards": "Dashboards maximum number", + "maximum-dashboards-required": "Dashboards maximum number is required.", + "maximum-dashboards-range": "Dashboards maximum number can't be negative", + "maximum-rule-chains": "Rule chains maximum number", + "maximum-rule-chains-required": "Rule chains maximum number is required.", + "maximum-rule-chains-range": "Rule chains maximum number can't be negative", + "maximum-integrations": "Integrations maximum number", + "maximum-integrations-required": "Integrations maximum number is required.", + "maximum-integrations-range": "Integrations maximum number can't be negative", + "maximum-converters": "Converters maximum number", + "maximum-converters-required": "Converters maximum number is required.", + "maximum-converters-range": "Converters maximum number can't be negative", + "maximum-scheduler-events": "Scheduler events maximum number", + "maximum-scheduler-events-required": "Scheduler events maximum number is required.", + "maximum-scheduler-events-range": "Scheduler events maximum number can't be negative", + "maximum-resources-sum-data-size": "Resource files sum size", + "maximum-resources-sum-data-size-required": "Resource files sum size is required.", + "maximum-resources-sum-data-size-range": "Resource files sum size can`t be negative", + "maximum-ota-packages-sum-data-size": "OTA package files sum size", + "maximum-ota-package-sum-data-size-required": "OTA package files sum size is required.", + "maximum-ota-package-sum-data-size-range": "OTA package files sum size can`t be negative", + "rest-requests-for-tenant": "REST requests for tenant", + "transport-tenant-telemetry-msg-rate-limit": "Transport tenant telemetry messages", + "transport-tenant-telemetry-data-points-rate-limit": "Transport tenant telemetry data points", + "transport-device-msg-rate-limit": "Transport device messages", + "transport-device-telemetry-msg-rate-limit": "Transport device telemetry messages", + "transport-device-telemetry-data-points-rate-limit": "Transport device telemetry data points", + "tenant-entity-export-rate-limit": "Entity version creation", + "tenant-entity-import-rate-limit": "Entity version load", + "tenant-notification-request-rate-limit": "Notification requests", + "tenant-notification-requests-per-rule-rate-limit": "Notification requests per notification rule", + "integration-msgs-per-tenant-rate-limit": "Tenant integration messages", + "integration-msgs-per-device-rate-limit": "Device integration messages", + "max-transport-messages": "Transport messages maximum number", + "max-transport-messages-required": "Transport messages maximum number is required.", + "max-transport-messages-range": "Transport messages maximum number can't be negative", + "max-transport-data-points": "Transport data points maximum number ", + "max-transport-data-points-required": "Transport data points maximum number is required.", + "max-transport-data-points-range": "Transport data points maximum number can't be negative", + "max-r-e-executions": "Rule Engine executions maximum number", + "max-r-e-executions-required": "Rule Engine executions maximum number is required.", + "max-r-e-executions-range": "Rule Engine executions maximum number can't be negative", + "max-j-s-executions": "JavaScript executions maximum number ", + "max-j-s-executions-required": "JavaScript executions maximum number is required.", + "max-j-s-executions-range": "JavaScript executions maximum number can't be negative", + "max-d-p-storage-days": "Data points storage days maximum number", + "max-d-p-storage-days-required": "Data points storage days maximum number is required.", + "max-d-p-storage-days-range": "Data points storage days maximum number can't be negative", + "default-storage-ttl-days": "Storage TTL days by default", + "default-storage-ttl-days-required": "Storage TTL days by default is required.", + "default-storage-ttl-days-range": "Storage TTL days by default can't be negative", + "alarms-ttl-days": "Alarms TTL days", + "alarms-ttl-days-required": "Alarms TTL days required", + "alarms-ttl-days-days-range": "Alarms TTL days can't be negative", + "rpc-ttl-days": "RPC TTL days", + "rpc-ttl-days-required": "RPC TTL days required", + "rpc-ttl-days-days-range": "RPC TTL days can't be negative", + "max-rule-node-executions-per-message": "Rule node per message executions maximum number", + "max-rule-node-executions-per-message-required": "MRule node per message executions maximum number is required.", + "max-rule-node-executions-per-message-range": "Rule node per message executions maximum number can't be negative", + "max-emails": "Emails sent maximum number", + "max-emails-required": "Emails sent maximum number is required.", + "max-emails-range": "Emails sent maximum number can't be negative", + "sms-enabled": "SMS enabled", + "max-sms": "SMS sent maximum number", + "max-sms-required": "SMS sent maximum number is required.", + "max-sms-range": "SMS sent maximum number can't be negative", + "max-created-alarms": "Alarms created maximum number", + "max-created-alarms-required": "Alarms created maximum number is required.", + "max-created-alarms-range": "Alarms created maximum number be negative", + "no-queue": "No Queue configured", + "add-queue": "Add Queue", + "queues-with-count": "Queues ({{count}})", + "tenant-rest-limits": "REST requests for tenant", + "customer-rest-limits": "REST requests for customer", + "incorrect-pattern-for-rate-limits": "The format is comma separated pairs of capacity and period (in seconds) with a colon between, e.g. 100:1,2000:60", + "too-small-value-zero": "The value must be bigger than 0", + "too-small-value-one": "The value must be bigger than 1", + "queue-size-is-limited-by-system-configuration": "The size of the queue is also limited by the system configuration.", + "cassandra-tenant-limits-configuration": "Cassandra query for tenant", + "ws-limit-max-sessions-per-tenant": "Sessions per tenant maximum number", + "ws-limit-max-sessions-per-customer": "Sessions per customer maximum number", + "ws-limit-max-sessions-per-regular-user": "Sessions per regular user maximum number", + "ws-limit-max-sessions-per-public-user": "Sessions per public user maximum number", + "ws-limit-queue-per-session": "Message queue per session maximum size", + "ws-limit-max-subscriptions-per-tenant": "Subscriptions per tenant maximum number", + "ws-limit-max-subscriptions-per-customer": "Subscriptions per customer maximum number", + "ws-limit-max-subscriptions-per-regular-user": "Subscriptions per regular user maximum number", + "ws-limit-max-subscriptions-per-public-user": "Subscriptions per public user maximum number", + "ws-limit-updates-per-session": "WS updates per session", + "rate-limits": { + "add-limit": "Add limit", + "advanced-settings": "Advanced settings", + "edit-limit": "Edit limit", + "but-less-than": "but less than", + "edit-transport-tenant-msg-title": "Edit transport tenant messages rate limits", + "edit-transport-tenant-telemetry-msg-title": "Edit transport tenant telemetry messages rate limits", + "edit-transport-tenant-telemetry-data-points-title": "Edit transport tenant telemetry data points rate limits", + "edit-transport-device-msg-title": "Edit transport device messages rate limits", + "edit-transport-device-telemetry-msg-title": "Edit transport device telemetry messages rate limits", + "edit-transport-device-telemetry-data-points-title": "Edit transport device telemetry data points rate limits", + "edit-tenant-rest-limits-title": "Edit REST requests for tenant rate limits", + "edit-customer-rest-limits-title": "Edit REST requests for customer rate limits", + "edit-ws-limit-updates-per-session-title": "Edit WS updates per session rate limits", + "edit-cassandra-tenant-limits-configuration-title": "Edit Cassandra query for tenant rate limits", + "edit-tenant-entity-export-rate-limit-title": "Edit entity version creation rate limits", + "edit-tenant-entity-import-rate-limit-title": "Edit entity version load rate limits", + "edit-tenant-notification-request-rate-limit-title": "Edit notification requests rate limits", + "edit-tenant-notification-requests-per-rule-rate-limit-title": "Edit notification requests per notification rule rate limits", + "edit-integration-msgs-per-tenant-rate-limit-title": "Edit integration messages per tenant rate limits", + "edit-integration-msgs-per-device-rate-limit-title": "Edit integration messages per device rate limits", + "messages-per": "messages per", + "not-set": "Not set", + "number-of-messages": "Number of messages", + "number-of-messages-required": "Number of messages is required.", + "number-of-messages-min": "Minimum value is 1.", + "preview": "Preview", + "per-seconds": "Per seconds", + "per-seconds-required": "Time rate is required.", + "per-seconds-min": "Minimum value is 1.", + "rate-limits": "Rate limits", + "remove-limit": "Remove limit", + "transport-tenant-msg": "Transport tenant messages", + "transport-tenant-telemetry-msg": "Transport tenant telemetry messages", + "transport-tenant-telemetry-data-points": "Transport tenant telemetry data points", + "transport-device-msg": "Transport device messages", + "transport-device-telemetry-msg": "Transport device telemetry messages", + "transport-device-telemetry-data-points": "Transport device telemetry data points", + "sec": "sec" + } + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, =1 {1 sekundė} other {# sekundės} }", + "minutes-interval": "{ minutes, plural, =1 {1 minutė} other {# minutės} }", + "hours-interval": "{ hours, plural, =1 {1 valanda} other {# valandos} }", + "days-interval": "{ days, plural, =1 {1 diena} other {# dienos} }", + "days": "Dienos", + "hours": "Valandos", + "minutes": "Minutės", + "seconds": "Sekundės", + "advanced": "Patyręs vartotojas", + "predefined": { + "yesterday": "Yesterday", + "day-before-yesterday": "Day before yesterday", + "this-day-last-week": "This day last week", + "previous-week": "Previous week (Sun - Sat)", + "previous-week-iso": "Previous week (Mon - Sun)", + "previous-month": "Previous month", + "previous-quarter": "Previous quarter", + "previous-half-year": "Previous half year", + "previous-year": "Previous year", + "current-hour": "Current hour", + "current-day": "Current day", + "current-day-so-far": "Current day so far", + "current-week": "Current week (Sun - Sat)", + "current-week-iso": "Current week (Mon - Sun)", + "current-week-so-far": "Current week so far (Sun - Sat)", + "current-week-iso-so-far": "Current week so far (Mon - Sun)", + "current-month": "Current month", + "current-month-so-far": "Current month so far", + "current-quarter": "Current quarter", + "current-quarter-so-far": "Current quarter so far", + "current-half-year": "Current half year", + "current-half-year-so-far": "Current half year so far", + "current-year": "Current year", + "current-year-so-far": "Current year so far" + } + }, + "timeunit": { + "milliseconds": "Milisekundės", + "seconds": "Sekundės", + "minutes": "Minutės", + "hours": "Valandos", + "days": "Dienos" + }, + "timewindow": { + "timewindow": "Laikotarpio vaizdas", + "years": "{ years, plural, =1 { metai } other {# metai } }", + "years-short": "{{ years }}m", + "months": "{ months, plural, =1 { mėnuo } other {# mėnesiai } }", + "months-short": "{{ months }}mėn", + "weeks": "{ weeks, plural, =1 { savaitė } other {# savaitės } }", + "weeks-short": "{{ weeks }}sav", + "days": "{ days, plural, =1 { diena } other {# dienos } }", + "days-short": "{{ days }}d", + "hours": "{ hours, plural, =0 { valandų } =1 {1 valanda } other {# valandos } }", + "hr": "{{ hr }} val", + "hr-short": "{{ hr }}val", + "minutes": "{ minutes, plural, =0 { minučių } =1 {1 minutė } other {# minutės } }", + "min": "{{ min }} min", + "min-short": "{{ min }}min", + "seconds": "{ seconds, plural, =0 { sekundžių } =1 {1 sekundė } other {# sekundės } }", + "sec": "{{ sec }} sek", + "sec-short": "{{ sec }}s", + "short": { + "days": "{ days, plural, =1 {1 diena } other {# dienos } }", + "hours": "{ hours, plural, =1 {1 valanda } other {# valandos } }", + "minutes": "{{minutes}} min ", + "seconds": "{{seconds}} sek " + }, + "realtime": "Realiu laiku", + "history": "istorija", + "last-prefix": "Paskutinės", + "period": "nuo {{ startTime }} iki {{ endTime }}", + "edit": "Redaguoti laikotarpio vaizdą", + "date-range": "Datų intervalas", + "for-all-time": "Viskas", + "last": "Paskutinės", + "time-period": "Laikotarpis", + "hide": "Slėpti", + "interval": "Interval", + "just-now": "Just now", + "just-now-lower": "just now", + "ago": "ago", + "style": "Timewindow style", + "icon": "Icon", + "icon-position": "Icon position", + "icon-position-left": "Left", + "icon-position-right": "Right", + "font": "Font", + "color": "Color", + "displayTypePrefix": "Display Realtime/History prefix", + "preview": "Preview" + }, + "unit": { + "millimeter": "Millimeter", + "centimeter": "Centimeter", + "angstrom": "Angstrom", + "nanometer": "Nanometer", + "micrometer": "Micrometer", + "meter": "Meter", + "kilometer": "Kilometer", + "inch": "Inch", + "foot": "Foot", + "yard": "Yard", + "mile": "Mile", + "nautical-mile": "Nautical Mile", + "astronomical-unit": "Astronomical Unit", + "reciprocal-metre": "Reciprocal Metre", + "meter-per-meter": "Meter per meter", + "steradian": "Steradian", + "thou": "Thou", + "barleycorn": "Barleycorn", + "hand": "Hand", + "chain": "Chain", + "furlong": "Furlong", + "league": "League", + "fathom": "Fathom", + "cable": "Cable", + "link": "Link", + "rod": "Rod", + "nanogram": "Nanogram", + "microgram": "Microgram", + "milligram": "Milligram", + "gram": "Gram", + "kilogram": "Kilogram", + "tonne": "Tonne", + "ounce": "Ounce", + "pound": "Pound", + "stone": "Stone", + "hundredweight-count": "Hundredweight count", + "short-tons": "Short tons", + "dalton": "Dalton", + "grain": "Grain", + "drachm": "Drachm", + "quarter": "Quarter", + "slug": "Slug", + "carat": "Carat", + "cubic-millimeter": "Cubic Millimeter", + "cubic-centimeter": "Cubic Centimeter", + "cubic-meter": "Cubic Meter/s", + "cubic-kilometer": "Cubic Kilometers", + "microliter": "Microliter", + "milliliter": "Milliliter", + "liter": "Liter", + "hectoliter": "Hectolitre", + "cubic-inch": "Cubic Inch", + "cubic-foot": "Cubic Foot", + "cubic-yard": "Cubic Yards", + "fluid-ounce": "Fluid Ounce", + "pint": "Pint", + "quart": "Quart", + "gallon": "Gallon", + "oil-barrels": "Oil Barrels", + "cubic-meter-per-kilogram": "Cubic Meter per Kilogram", + "gill": "Gill", + "hogshead": "Hogshead", + "teaspoon": "Teaspoon", + "tablespoon": "Tablespoon", + "cup": "Cup", + "celsius": "Celsius", + "kelvin": "Kelvin", + "rankine": "Rankine", + "fahrenheit": "Fahrenheit", + "percent": "Percent", + "meter-per-second": "Meter per Second", + "kilometer-per-hour": "Kilometer per Hour", + "foot-per-second": "Foot per Second", + "mile-per-hour": "Mile per Hour", + "knot": "Knot", + "millimeters-per-minute": "Millimeters per minute", + "kilometer-per-hour-squared": "Kilometer per hour squared", + "foot-per-second-squared": "Foot per second squared", + "pascal": "Pascal", + "kilopascal": "Kilopascal", + "megapascal": "Megapascal", + "gigapascal": "Gigapascal", + "millibar": "Millibar", + "bar": "Bar", + "kilobar": "Kilobar", + "newton": "Newton", + "newton-meter": "Newton meter", + "foot-pounds": "Foot-pounds", + "inch-pounds": "Inch-pounds", + "newton-per-meter": "Newton per meter", + "atmospheres": "Atmospheres", + "pounds-per-square-inch": "Pounds per Square Inch", + "torr": "Torr", + "inches-of-mercury": "Inches of Mercury", + "pascal-per-square-meter": "Pascal per Square Meter", + "pound-per-square-inch": "Pound per Square Inch", + "newton-per-square-meter": "Newton per Square Meter", + "kilogram-force-per-square-meter": "Kilogram-force per Square Meter", + "pascal-per-square-centimeter": "Pascal per Square Centimeter", + "ton-force-per-square-inch": "Ton-force per Square Inch", + "kilonewton-per-square-meter": "Kilonewton per Square Meter", + "newton-per-square-millimeter": "Newton per Square Millimeter", + "microjoule": "Microjoule", + "millijoule": "Millijoule", + "joule": "Joule", + "kilojoule": "Kilojoule", + "megajoule": "Megajoule", + "gigajoule": "Gigajoule", + "watt-hour": "Watt-hour", + "kilowatt-hour": "Kilowatt-hour", + "electron-volts": "Electron volts", + "joules-per-coulomb": "Joules per Coulomb", + "british-thermal-unit": "British Thermal Units", + "foot-pound": "Foot-pound", + "calorie": "Calorie", + "small-calorie": "Small Calorie", + "kilocalorie": "Kilocalorie", + "joule-per-kelvin": "Joule per Kelvin", + "joule-per-kilogram-kelvin": "Joule per Kilogram-Kelvin", + "joule-per-kilogram": "Joule per Kilogram", + "watt-per-meter-kelvin": "Watt per Meter-Kelvin", + "joule-per-cubic-meter": "Joule per Cubic Meter", + "therm": "Therm", + "electric-dipole-moment": "Electric Dipole Moment", + "magnetic-dipole-moment": "Magnetic Dipole Moment", + "debye": "Debye", + "coulomb-per-square-meter-per-volt": "Coulomb per Square Meter per Volt", + "milliwatt": "Milliwatt", + "microwatt": "Microwatt", + "watt": "Watt", + "kilowatt": "Kilowatt", + "megawatt": "Megawatt", + "gigawatt": "Gigawatt", + "metric-horsepower": "Metric Horsepower", + "milliwatt-per-square-centimeter": "Milliwatts per square centimeter", + "watt-per-square-centimeter": "Watts per square centimeter", + "kilowatt-per-square-centimeter": "Kilowatts per square centimeter", + "milliwatt-per-square-meter": "Milliwatts per square meter", + "watt-per-square-meter": "Watts per square meter", + "kilowatt-per-square-meter": "Kilowatts per square meter", + "watt-per-square-inch": "Watts per square inch", + "kilowatt-per-square-inch": "Kilowatts per square inch", + "horsepower": "Horsepower", + "btu-per-hour": "British thermal units/hour", + "coulomb": "Coulomb", + "millicoulomb": "Millicoulombs", + "microcoulomb": "Microcoulomb", + "picocoulomb": "Picocoulomb", + "coulomb-per-meter": "Coulomb per meter", + "coulomb-per-cubic-meter": "Coulomb per Cubic Meter", + "coulomb-per-square-meter": "Coulomb per Square Meter", + "square-millimeter": "Square Millimeter", + "square-centimeter": "Square Centimeter", + "square-meter": "Square Meter", + "hectare": "Hectare", + "square-kilometer": "Square Kilometer", + "square-inch": "Square Inch", + "square-foot": "Square Foot", + "square-yard": "Square Yard", + "acre": "Acre", + "square-mile": "Square Mile", + "are": "Are", + "barn": "Barn", + "circular-inch": "Circular Inch", + "milliampere-hour": "Milliampere-hour", + "milliampere-hour-tags": "electric current, current flow, electric charge, current capacity, flow of electricity, electrical flow, milliampere-hour, milliampere-hours, mAh", + "ampere-hours": "Ampere-hours", + "ampere-hours-tags": "electric current, current flow, electric charge, current capacity, flow of electricity, electrical flow, ampere, ampere-hours, Ah", + "kiloampere-hours": "Kiloampere-hours", + "kiloampere-hours-tags": "electric current, current flow, electric charge, current capacity, flow of electricity, electrical flow, kiloampere-hours, kiloampere-hour, kAh", + "nanoampere": "Nanoampere", + "nanoampere-tags": "current, amperes, nanoampere, nA", + "picoampere": "Picoampere", + "picoampere-tags": "current, amperes, picoampere, pA", + "microampere": "Microampere", + "microampere-tags": "electric current, microampere, microamperes, μA", + "milliampere": "Milliampere", + "milliampere-tags": "electric current, milliampere, milliamperes, mA", + "ampere": "Ampere", + "ampere-tags": "electric current, current flow, flow of electricity, electrical flow, ampere, amperes, amperage, A", + "kiloamperes": "Kiloamperes", + "kiloamperes-tags": "electric current, current flow, kiloamperes, kA", + "microampere-per-square-centimeter": "Microampere per square centimeter", + "microampere-per-square-centimeter-tags": "Current density, microampere per square centimeter, µA/cm²", + "ampere-per-square-meter": "Ampere per Square Meter", + "ampere-per-square-meter-tags": "current density, current per unit area, ampere per square meter, A/m²", + "ampere-per-meter": "Ampere per Meter", + "ampere-per-meter-tags": "magnetic field strength, magnetic field intensity, ampere per meter, A/m", + "oersted": "Oersted", + "oersted-tags": "magnetic field, oersted, Oe", + "bohr-magneton": "Bohr Magneton", + "bohr-magneton-tags": "atomic physics, magnetic moment, bohr magneton, μB", + "ampere-meter-squared": "Ampere-Meter Squared", + "ampere-meter-squared-tags": "magnetic moment, dipole moment, ampere-meter squared, A·m²", + "ampere-meter": "Ampere-Meter", + "ampere-meter-tags": "magnetic field, current loop, ampere-meter, A·m", + "nanovolt": "Nanovolt", + "picovolt": "Picovolt", + "millivolts": "Millivolts", + "microvolts": "Microvolts", + "volt": "Volt", + "kilovolts": "Kilovolts", + "dbmV": "dBmV", + "volt-meter": "Volt-Meter", + "kilovolt-meter": "Kilovolt-Meter", + "megavolt-meter": "Megavolt-Meter", + "microvolt-meter": "Microvolt-Meter", + "millivolt-meter": "Millivolt-Meter", + "nanovolt-meter": "Nanovolt-Meter", + "ohm": "Ohm", + "microohm": "Microohm", + "milliohm": "Milliohm", + "kilohm": "Kilohm", + "megohm": "Megohm", + "gigohm": "Gigohm", + "hertz": "Hertz", + "kilohertz": "Kilohertz", + "megahertz": "Megahertz", + "gigahertz": "Gigahertz", + "rpm": "Revolutions Per Minute", + "candela-per-square-meter": "Candela per square meter", + "candela": "Candela", + "lumen": "Lumen", + "lux": "Lux", + "foot-candle": "Foot-candle", + "lumen-per-square-meter": "Lumen per square meter", + "lux-second": "Lux second", + "lumen-second": "Lumen second", + "lumens-per-watt": "Lumens per watt", + "absorbance": "Absorbance", + "mole": "Mole", + "nanomole": "Nanomole", + "micromole": "MicroMole", + "millimole": "Millimole", + "kilomole": "Kilomole", + "mole-per-cubic-meter": "Mole per Cubic Meter", + "rssi": "RSSI", + "ppm": "Parts Per Million", + "ppb": "Parts Per Billion", + "micrograms-per-cubic-meter": "Micrograms per Cubic Meter", + "aqi": "AQI", + "gram-per-cubic-meter": "Gram per cubic meter", + "gram-per-kilogram": "Specific Humidity", + "millimeters-per-second": "Millimeters per second", + "neper": "Neper", + "bel": "Bel", + "decibel": "Decibel", + "meters-per-second-squared": "Meters per second squared", + "becquerel": "Becquerel", + "curie": "Curie", + "gray": "Gray", + "sievert": "Sievert", + "roentgen": "Roentgen", + "cps": "Counts per Second", + "rad": "Rad", + "rem": "Rem", + "dps": "Disintegrations per second", + "rutherford": "Rutherford", + "coulombs-per-kilogram": "Coulombs per kilogram", + "becquerels-per-cubic-meter": "Becquerels per cubic meter", + "curies-per-liter": "Curies per liter", + "becquerels-per-second": "Becquerels per second", + "curies-per-second": "Curies per second", + "gy-per-second": "Gray per Second", + "watt-per-steradian": "Watt per Steradian", + "watt-per-square-metre-steradian": "Watt per Square Metre-Steradian", + "ph-level": "pH Level", + "turbidity": "Turbidity", + "mg-per-liter": "Milligrams per liter", + "microsiemens-per-centimeter": "Microsiemens per centimeter", + "millisiemens-per-meter": "Millisiemens per meter", + "siemens-per-meter": "Siemens per meter", + "kilogram-per-cubic-meter": "Kilogram per cubic meter", + "gram-per-cubic-centimeter": "Gram per cubic centimeter", + "kilogram-per-square-meter": "Kilogram per square metre", + "milligram-per-milliliter": "Milligram per milliliter", + "pound-per-cubic-foot": "Pound per cubic foot", + "ounces-per-cubic-inch": "Ounces per cubic inch", + "tons-per-cubic-yard": "Tons per cubic yard", + "particle-density": "Particle density", + "kilometers-per-liter": "Kilometers per liter", + "miles-per-gallon": "Miles per gallon", + "liters-per-100-km": "Liters per 100 km", + "gallons-per-mile": "Gallons per mile", + "liters-per-hour": "Liters per hour", + "gallons-per-hour": "Gallons per hour", + "beats-per-minute": "Beats per minute", + "millimeters-of-mercury": "Millimeters of mercury", + "milligrams-per-deciliter": "Milligrams per deciliter", + "g-force": "G-force", + "kilonewton": "Kilonewton", + "kilogram-force": "Kilogram-Force", + "pound-force": "Pound-Force", + "kilopound-force": "Kilopound-Force", + "dyne": "Dyne", + "poundal": "Poundal", + "kip": "Kip", + "gal": "Gal", + "gravity": "Gravity", + "hectopascal": "Hectopascal", + "atmosphere": "Atmosphere", + "millibars": "Millibars", + "inch-of-mercury": "One inch of mercury", + "richter-scale": "Richter Scale", + "second": "Second", + "minute": "Minute", + "hour": "Hour", + "day": "Day", + "week": "Week", + "month": "Month", + "year": "Year", + "cubic-foot-per-minute": "Cubic Foot Per Minute", + "cubic-meters-per-hour": "Cubic Meters Per Hour", + "cubic-meters-per-second": "Cubic Meters Per Second", + "liter-per-second": "Liter Per Second", + "liter-per-minute": "Liter Per Minute", + "gallons-per-minute": "Gallons Per Minute", + "cubic-foot-per-second": "Cubic foot per second", + "milliliters-per-minute": "Milliliters per minute", + "bit": "Bit", + "byte": "Byte", + "kilobyte": "Kilobyte", + "megabyte": "Megabyte", + "gigabyte": "Gigabyte", + "terabyte": "Terabyte", + "petabyte": "Petabyte", + "exabyte": "Exabyte", + "zettabyte": "Zettabyte", + "yottabyte": "Yottabyte", + "bit-per-second": "Bit per second", + "kilobit-per-second": "Kilobit per second", + "megabit-per-second": "Megabit per second", + "gigabit-per-second": "Gigabit per second", + "terabit-per-second": "Terabit per second", + "byte-per-second": "Byte per second", + "kilobyte-per-second": "Kilobyte per second", + "megabyte-per-second": "Megabyte per second", + "gigabyte-per-second": "Gigabyte per second", + "degree": "Degree", + "radian": "Radian", + "gradian": "Gradian", + "mil": "Mil", + "revolution": "Revolution", + "siemens": "Siemens", + "millisiemens": "Millisiemens", + "microsiemens": "Microsiemens", + "kilosiemens": "Kilosiemens", + "megasiemens": "Megasiemens", + "gigasiemens": "Gigasiemens", + "farad": "Farad", + "millifarad": "Millifarad", + "microfarad": "Microfarad", + "nanofarad": "Nanofarad", + "picofarad": "Picofarad", + "kilofarad": "Kilofarad", + "megafarad": "Megafarad", + "gigafarad": "Gigafarad", + "terfarad": "Terfarad", + "farad-per-meter": "Farad per Meter", + "tesla": "Tesla", + "gauss": "Gauss", + "kilogauss": "Kilogauss", + "millitesla": "Millitesla", + "microtesla": "Microtesla", + "nanotesla": "Nanotesla", + "kilotesla": "Kilotesla", + "megatesla": "Megatesla", + "millitesla-square-meters": "millitesla square meters", + "gamma": "Gamma", + "lambda": "Lambda", + "square-meter-per-second": "Square meter per second", + "square-centimeter-per-second": "Square centimeter per second", + "stoke": "Stoke", + "centistokes": "Centistokes", + "square-foot-per-second": "Square foot per second", + "square-inch-per-second": "Square inch per second", + "pascal-second": "Pascal-second", + "centipoise": "Centipoise", + "poise": "Poise", + "reynolds": "Reynolds", + "pound-per-foot-hour": "Pound per foot-hour", + "newton-second-per-square-meter": "Newton second per square meter", + "dyne-second-per-square-centimeter": "Dyne second per square centimeter", + "kilogram-per-meter-second": "Kilogram per meter-second", + "tesla-square-meters": "Tesla square meters", + "maxwell": "Maxwell", + "tesla-per-meter": "Tesla per Meter", + "gauss-per-centimeter": "Gauss per Centimeter", + "weber": "Weber", + "microweber": "Microweber", + "milliweber": "Milliweber", + "gauss-square-centimeter": "Gauss-Square Centimeter", + "kilogauss-square-centimeter": "Kilogauss-Square Centimeter", + "henry": "Henry", + "millihenry": "Millihenry", + "microhenry": "Microhenry", + "nanohenry": "Nanohenry", + "henry-per-meter": "Henry per Meter", + "tesla-meter-per-ampere": "Tesla Meter per Ampere", + "gauss-per-oersted": "Gauss per Oersted", + "kilogram-per-mole": "Kilogram per mole", + "gram-per-mole": "Gram per mole", + "milligram-per-mole": "Milligram per mole", + "joule-per-mole": "Joule per Mole", + "joule-per-mole-kelvin": "Joule per Mole-Kelvin", + "millivolts-per-meter": "Millivolts per meter", + "volts-per-meter": "Volts per meter", + "kilovolts-per-meter": "Kilovolts per meter", + "radian-per-second": "Radian per second", + "radian-per-second-squared": "Radian per second squared", + "revolutions-per-minute-per-second": "Angular acceleration", + "revolutions-per-minute-per-second-squared": "Angular Acceleration", + "deg-per-second": "deg/s", + "degrees-brix": "Degrees Brix", + "katal": "Katal", + "katal-per-cubic-metre": "Katal per Cubic Metre" + }, + "user": { + "all": "Visi", + "all-users": "Visi vartotojai", + "groups": "Grupės", + "user": "Vartotojas", + "users": "Vartotojai", + "management": "Vartotojų valdymas", + "customer-users": "Kliento vartotojai", + "tenant-admins": "Valdytojų administratoriai", + "sys-admin": "Sistemos administratoriai", + "tenant-admin": "Valdytojo administratoriai", + "customer": "Klientas", + "anonymous": "Anonimas", + "add": "Pridėti vartotoją", + "delete": "Panaikinti vartotoją", + "add-user-text": "Pridėti naują vartotoją", + "no-users-text": "Vartotojų nėra", + "user-details": "Informacija apie vartotoją", + "delete-users": "Panaikinti vartotojus", + "delete-user-title": "Ar tikrai norite panaikinti vartotoją '{{userEmail}}'?", + "delete-user-text": "Būkite dėmesingi, po patvirtinimo vartotojas ir visa su juo susijusi informacija bus panaikinta.", + "delete-users-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 vatotoją} other {# vartotojus} }?", + "delete-users-action-title": "Panaikinti { count, plural, =1 {1 vartotoją} other {# vartotojus} }", + "delete-users-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti vartotojai ir su jis susijusi informacija bus panaikinta.", + "activation-email-sent-message": "Aktyvinimo el. laiškas sėkmingai išsiųstas!", + "resend-activation": "Iš naujo siųsti aktyvinimą", + "email": "El. pašto adresas", + "email-required": "El. pašto adresas būtinas.", + "invalid-email-format": "Neteisingas el. pašto adreso formatas.", + "first-name": "Vardas", + "last-name": "Pavardė", + "description": "Aprašymas", + "default-dashboard": "Pagrindinis skydelis", + "always-fullscreen": "Visada per visą ekraną", + "select-user": "Pasirinkti vartotoją", + "no-users-matching": "Vartotojų, atitinkančių'{{entity}}' nėra.", + "user-required": "Vartotojas būtinas", + "activation-method": "Aktyvavimo būdas", + "display-activation-link": "Rodyti aktyvavimo nuorodą", + "send-activation-mail": "Siųsti aktyvavimo el. laišką", + "activation-link": "Vartotojo aktyvavimo nuoroda", + "activation-link-text": "Vartotojui aktyvuoti, paspauskite nuorodą :", + "copy-activation-link": "Kopijuoti aktyvavimo nuorodą", + "activation-link-copied-message": "Vartotojo aktyvavimo nuoroda nukopijuota į iškarpinę", + "details": "Informacija", + "login-as-tenant-admin": "Prisijunkti kaip valdytojo administratorius", + "login-as-customer-user": "Prisijunkti kaip kliento vartotojas", + "select-group-to-add": "Pasirinkite grupę, kuriai priskirti pažymėtus vartotojus", + "select-group-to-move": "Pasirinkite grupę, į kurią perkelti pažymėtus vartotojus", + "remove-users-from-group": "Ar tikrai norite pašalinti { count, plural, =1 {1 vartotoją} other {# vartotojus} } iš grupės '{{entityGroup}}'?", + "group": "Vartotojų grupė", + "list-of-groups": "{ count, plural, =1 {Viena vartotojų grupė} other {# Vartotojų grupių sąrašas} }", + "group-name-starts-with": "Vartotojų grupės, kurios prasideda '{{prefix}}'", + "search": "Vartotojų paieška", + "selected-users": "Pasirinkta { count, plural, =1 {1 vartotojas} other {# vartotojai} }", + "disable-account": "Išjungti vartotojo paskyrą", + "enable-account": "Įjungti vartotojo paskyrą", + "enable-account-message": "Vartotojo paskyra sėkmingai įjungta!", + "disable-account-message": "Vartotojo paskyra sėkmingai išjungta!", + "copyId": "Kopijuoti vartotojo Id", + "idCopiedMessage": "Vartotojo Id nukopiuotas į iškarpinę", + "user-list": "Vartotojų sąrašas", + "user-list-required": "Vartotojų sąrašas būtinas" + }, + "value": { + "type": "Reikšmės tipas", + "string": "Tekstas", + "string-value": "Tekstinė informacija", + "string-value-required": "Tekstinė informacija būtina", + "integer": "Sveikasis skaičius", + "integer-value": "Sveikojo skaičiaus reikšmė", + "integer-value-required": "Sveikojo skaičiaus reikšmė būtina", + "invalid-integer-value": "Sveikojo skaičiaus reikšmė neteisinga", + "double": "Realusis skaičius", + "double-value": "Realiojo skaičiaus reikšmė", + "double-value-required": "Realiojo skaičiaus reikšmė būtina", + "boolean": "Loginis", + "boolean-value": "Loginė reikšmė", + "false": "Netiesa", + "true": "Tiesa", + "long": "Sveikas skaičius", + "json": "JSON", + "json-value": "JSON reikšmė", + "json-value-invalid": "Neteisingas JSON reikšmės formatas", + "json-value-required": "JSON reikšmė būtina." + }, + "version-control": { + "version-control": "Version control", + "management": "Version control management", + "search": "Search versions", + "branch": "Branch", + "default": "Default", + "select-branch": "Select branch", + "branch-required": "Branch is required", + "create-entity-version": "Create entity version", + "version-name": "Version name", + "version-name-required": "Version name is required", + "author": "Author", + "export-relations": "Export relations", + "export-attributes": "Export attributes", + "export-credentials": "Export credentials", + "export-group-entities": "Export group entities", + "export-roles": "Export roles", + "entity-versions": "Entity versions", + "versions": "Versions", + "created-time": "Created time", + "version-id": "Version ID", + "no-entity-versions-text": "No entity versions found", + "no-versions-text": "No versions found", + "copy-full-version-id": "Copy full version id", + "create-version": "Create version", + "creating-version": "Creating version... Please wait", + "nothing-to-commit": "No changes to commit", + "restore-version": "Restore version", + "restore-entity-from-version": "Restore entity from version '{{versionName}}'", + "restoring-entity-version": "Restoring entity version... Please wait", + "load-relations": "Load relations", + "load-attributes": "Load attributes", + "load-credentials": "Load credentials", + "load-group-entities": "Load group entities", + "load-roles": "Load roles", + "compare-with-current": "Compare with current", + "diff-entity-with-version": "Diff with entity version '{{versionName}}'", + "previous-difference": "Previous Difference", + "next-difference": "Next Difference", + "current": "Current", + "differences": "{ count, plural, =1 {1 difference} other {# differences} }", + "create-entities-version": "Create entities version", + "default-sync-strategy": "Default sync strategy", + "sync-strategy-merge": "Merge", + "sync-strategy-overwrite": "Overwrite", + "entities-to-export": "Entities to export", + "entities-to-restore": "Entities to restore", + "sync-strategy": "Sync strategy", + "all-entities": "All entities", + "no-entities-to-export-prompt": "Please specify entities to export", + "no-entities-to-restore-prompt": "Please specify entities to restore", + "add-entity-type": "Add entity type", + "remove-all": "Remove all", + "version-create-result": "{ added, plural, =0 {No entities} =1 {1 entity} other {# entities} } added.
{ modified, plural, =0 {No entities} =1 {1 entity} other {# entities} } modified.
{ removed, plural, =0 {No entities} =1 {1 entity} other {# entities} } removed.", + "remove-other-entities": "Remove other entities", + "find-existing-entity-by-name": "Find existing entity by name", + "restore-entities-from-version": "Restore entities from version '{{versionName}}'", + "restoring-entities-from-version": "Restoring entities... Please wait", + "no-entities-restored": "No entities restored", + "created": "{{created}} created", + "updated": "{{updated}} updated", + "deleted": "{{deleted}} deleted", + "groups-created": "{ created, plural, =1 {1 group} other {# groups} } created", + "groups-updated": "{ updated, plural, =1 {1 group} other {# groups} } updated", + "groups-deleted": "{ deleted, plural, =1 {1 group} other {# groups} } deleted", + "remove-other-entities-confirm-text": "Be careful! This will permanently delete all current entities
not present in the version you want to restore.

Please type \"remove other entities\" to confirm.", + "auto-commit-to-branch": "auto-commit to {{ branch }} branch", + "default-create-entity-version-name": "{{entityName}} update", + "sync-strategy-merge-hint": "Creates or updates selected entities in the repository. All other repository entities are not modified.", + "sync-strategy-overwrite-hint": "Creates or updates selected entities in the repository. All other repository entities are deleted.", + "device-credentials-conflict": "Failed to load the device with external id {{entityId}}
due to the same credentials are already present in the database for another device.
Please consider disabling the load credentials setting in the restore form.", + "missing-referenced-entity": "Failed to load the {{sourceEntityTypeName}} with external id {{sourceEntityId}}
because it references missing {{targetEntityTypeName}} with id {{targetEntityId}}.", + "add-entity-groups": "Add entity groups", + "entity-groups": "Entity groups", + "integration-routing-key-conflict": "Failed to load the integration with external id {{entityId}}
due to the same integration key is already present in the database for another integration.
Please consider enabling the auto-generate integration key setting in the restore form.", + "auto-generate-integration-key": "Auto-generate integration key", + "runtime-failed": "Failed: {{message}}", + "auto-commit-settings-read-only-hint": "Auto-commit feature doesn't work with enabled read-only option in Repository settings." + }, + "widget": { + "widget-library": "Valdiklių galerija", + "widget-bundle": "Valdiklių rinkinys", + "all-bundles": "Visi rinkiniai", + "select-widgets-bundle": "Pasirinkti valdiklių rinkinį", + "widgets": "Valdikliai", + "all-widgets": "Visi valdikliai", + "widget": "Valdiklis", + "select-widget": "Pasirinkti valdiklį", + "no-widgets-matching": "Valdiklių atitinkančių '{{entity}}' nėra.", + "no-widgets": "Valdiklių dar nėra", + "no-widgets-text": "Valdiklių nėra", + "management": "Valdiklių valdymas", + "editor": "Valdiklių redaktorius", + "widget-type-not-found": "Įkeliant valdiklio konfigūraciją kilo klaidų.
Gali būti, kad\n toks valdiklio tipas panaikintas.", + "widget-type-load-error": "Valdiklis neįkeltas, nes iškilo klaidų:", + "remove": "Panaikinti valdiklį", + "delete": "Panaikinti valdiklį", + "edit": "Redaguoti valdiklį", + "edit-widget-type": "Redaguoti valdikliį", + "remove-widget-title": "Ar tikrai norite panaikinti valdiklį '{{widgetTitle}}'?", + "remove-widget-text": "Būkite dėmesingi, po patvirtinimo valdiklis ir visa su juo susijusi informacija bus pašalinta.", + "timeseries": "Telemetrija", + "search-data": "Duomenų paieška", + "no-data-found": "Duomenų nėra", + "latest": "Naujausios reikšmės", + "rpc": "Valdymo valdiklis", + "alarm": "Įspėjimų valdiklis", + "static": "Statinis valdiklis", + "select-widget-type": "Pasirinkite valdiklio tipą", + "missing-widget-title-error": "Valdiklio pavadinimas būtinas!", + "widget-saved": "Valdiklis išsaugotas", + "unable-to-save-widget-error": "Valdiklio išsaugoti negalima! Valdiklyje yra klaidų!", + "save": "Valdiklis išsaugotas", + "saveAs": "valdiklį išsaugoti kaip", + "move": "Perkelti valdiklį", + "save-widget-type-as": "Valdiklį išsaugoti kaip", + "save-widget-type-as-text": "Įrašykite naują valdiklio pavadinimą", + "toggle-fullscreen": "Perjungti į viso ekrano režimą", + "run": "Paleisti valdiklį", + "widget-title": "Pavadinimas", + "title": "Pavadinimas", + "title-required": "Valdiklio pavadinimas būtinas.", + "title-max-length": "Pavadinimas negali viršyti 256 simbolių", + "system": "Sisteminis", + "type": "Tipas", + "resources": "Resursai", + "resource-url": "JavaScript/CSS URL", + "resource-is-module": "Is module", + "remove-resource": "Panaikinti resursą", + "add-resource": "Pridėti resursą", + "html": "HTML", + "tidy": "Tidy", + "css": "CSS", + "settings-schema": "Nustatymų schema", + "datakey-settings-schema": "Duomenų raktų nustatymų schema", + "latest-datakey-settings-schema": "Latest data key settings schema", + "widget-settings": "Valdiklio nustatymai", + "description": "Aprašymas", + "image-preview": "Paveiksliuko peržiūra", + "settings-form-selector": "Settings form selector", + "data-key-settings-form-selector": "Data key settings form selector", + "latest-data-key-settings-form-selector": "Latest data key settings form selector", + "all": "All", + "actual": "Actual", + "deprecated": "Pasenęs", + "has-basic-mode": "Has basic mode", + "basic-mode-form-selector": "Basic mode form selector", + "basic-mode": "Basic", + "advanced-mode": "Advanced", + "javascript": "Javascript", + "js": "JS", + "remove-widget-type-title": "Ar tikrai norite pašalinti valdiklį '{{widgetName}}'?", + "remove-widget-type-text": "Būkite dėmesingi, po patvirtinimo valdiklis ir visa su juo susijusi informacija bus pašalinta.", + "remove-widget-type": "Panaikinti valdiklį", + "widget-types": "Valdikliai", + "delete-widget-type-title": "Ar tikrai norite panaikinti valdiklį '{{widgetTypeName}}'?", + "delete-widget-type-text": "Būkite dėmesingi, po patvirtinimo valdiklis ir visa su juo susijusi informacija bus pašalinta.", + "delete-widget-types-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 valdiklį} other {# valdiklius} }?", + "delete-widget-types-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti valdikliai ir su jais susijusi informacija bus pašalinta.", + "delete-widget-type": "Panaikinti valdiklį", + "add-widget-type": "Pridėti naują valdiklį", + "widget-type-load-failed-error": "Valdiklio įkelti nepavyko!", + "widget-template-load-failed-error": "Valdiklio šablono įkelti nepavyko!", + "details": "Informacija", + "widget-type-details": "Informacija apie valdiklį", + "add": "Pridėti valdiklį", + "no-widget-types-text": "Valdiklių nėra", + "search-widget-types": "Valdiklių paieška", + "selected-widget-types": "Pasirinkta { count, plural, =1 {1 valdiklis} other {# valdikliai} }", + "undo": "Atšaukti valdiklio pakeitimus", + "export": "Eksportuoti valdiklį", + "export-data": "Eksportuoti valdiklio duomenis", + "export-to-csv": "Eksportuoti valdiklio duomenis į CSV...", + "export-to-excel": "Eksportuoti valdiklio duomenis į XLS...", + "export-to-excel-xlsx": "Eksportuoti valdiklio duomenis į XLSX...", + "no-data": "Duomenų atvaizdavimui nėra", + "data-overflow": "Valdiklyje rodoma {{count}} iš {{total}} įrašų", + "alarm-data-overflow": "Valdiklyje rodomi {{allowedEntities}} (didižiausias leistinas skaičius) įspėjimų įrašai iš {{totalEntities}}", + "search": "Valdiklių paieška", + "filter": "Valdiklių filtro tipas", + "loading-widgets": "Valdikliai įkeliami...", + "widget-template-error": "Netinkamas valdiklio HTML šablonas." + }, + "widget-action": { + "header-button": "Valdiklio antraštės mygtukas", + "open-dashboard-state": "Eiti į naują skydelio būseną", + "update-dashboard-state": "Atnaujinti dabartinę skydelio būseną", + "open-dashboard": "Eiti į kitą skydelio būseną", + "custom": "Aprašomas veiksmas", + "custom-pretty": "Aprašomas veiksmas (su HTML šablonu)", + "custom-pretty-error-title": "Custom dialog error", + "custom-pretty-template-error": "Invalid custom dialog template.", + "custom-pretty-controller-error": "Error occurred while evaluating custom dialog function.", + "mobile-action": "Mobile action", + "target-dashboard-state": "Tikslinė skydelio būsena", + "target-dashboard-state-required": "Tikslnė skydelio būsena būtina", + "set-entity-from-widget": "Nustatyti subjektą iš valdiklio", + "target-dashboard": "Tikslinis skydelis", + "open-right-layout": "Atidarykiti tinkamą skydelio išdėstymą (vaizdas mobiliesiems)", + "state-display-type": "Skydelio būsenos rodymo parinktis", + "open-normal": "Normal", + "open-in-separate-dialog": "Open in separate dialog", + "open-in-popover": "Open in popover", + "dialog-title": "Dialog title", + "dialog-hide-dashboard-toolbar": "Hide dashboard toolbar in dialog", + "dialog-width": "Dialog width in percents relative to viewport width", + "dialog-height": "Dialog height in percents relative to viewport height", + "dialog-size-range-error": "Dialog size percent value should be in a range from 1 to 100.", + "popover-preferred-placement": "Preferred popover placement", + "popover-placement-top": "Top", + "popover-placement-topLeft": "Top left", + "popover-placement-topRight": "Top right", + "popover-placement-right": "Right", + "popover-placement-rightTop": "Right top", + "popover-placement-rightBottom": "Right bottom", + "popover-placement-bottom": "Bottom", + "popover-placement-bottomLeft": "Bottom left", + "popover-placement-bottomRight": "Bottom right", + "popover-placement-left": "Left", + "popover-placement-leftTop": "Left top", + "popover-placement-leftBottom": "Left bottom", + "popover-hide-on-click-outside": "Hide popover on outside click", + "popover-hide-dashboard-toolbar": "Hide dashboard toolbar in popover", + "popover-width": "Popover width in browser units (ex. 100px, 25vw)", + "popover-height": "Popover height in browser units (ex. 100px, 25vh)", + "popover-style": "Popover style", + "open-new-browser-tab": "Open in a new browser tab", + "mobile": { + "action-type": "Mobile action type", + "action-type-required": "Mobile action type is required", + "take-picture-from-gallery": "Take picture from gallery", + "take-photo": "Take photo", + "map-direction": "Open map directions", + "map-location": "Open map location", + "scan-qr-code": "Scan QR Code", + "make-phone-call": "Make phone call", + "get-location": "Get phone location", + "take-screenshot": "Take screenshot" + } + }, + "widgets-bundle": { + "current": "Dabartinis rinkinys", + "widgets-bundles": "Valdiklių rinkiniai", + "widgets-bundle-widgets": "Valdiklių rinkinio valdikliai", + "add": "Pridėti valdiklių rinkinį", + "delete": "Panaikinti valdiklių rinkinį", + "title": "Pavadinimas", + "title-required": "Pavadinimas būtinas.", + "title-max-length": "Pavadinimas negali viršyti 256 simbolių", + "description": "Aprašymas", + "image-preview": "Paveiksliuko peržiūra", + "add-widgets-bundle-text": "Pridėti naują valdiklių rinkinį", + "no-widgets-bundles-text": "Valdiklių rinkinių nėra", + "empty": "Valdiklių rinkinys tuščias", + "details": "Informacija", + "widgets-bundle-details": "Informacija apie valdiklių rinkinį", + "delete-widgets-bundle-title": "Ar tikrai norite panaikinti valdiklių rinkinį '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Būkite dėmesingi, po patvirtinimo valdiklių rinkinys ir visa su juo susijusi informacija bus panaikinta.", + "delete-widgets-bundles-title": "Ar tikrai norite panaikinti { count, plural, =1 {1 valdiklių rinkinį} other {# valdiklių rinkinius} }?", + "delete-widgets-bundles-action-title": "Panaikinti { count, plural, =1 {1 valdiklių rinkinį} other {# valdiklių rinknius} }", + "delete-widgets-bundles-text": "Būkite dėmesingi, po patvirtinimo visi pasirinkti valdiklių rinkiniai ir su jais susijusi informacija bus panaikinta.", + "no-widgets-bundles-matching": "Valdiklių rinkinių, atitinkančių '{{widgetsBundle}}' nėra.", + "widgets-bundle-required": "Valdiklių rinkinys būtinas.", + "system": "Sisteminis", + "import": "Importuoti valdiklių rinknį", + "export": "Eksportuoti valdiklių rinkinį", + "export-widgets-bundle-widgets-prompt": "Valdiklių rinkinių tipus įtraukti į eksportuotus duomenis (kitu atveju bus eksportuojami tik nurodytų valdiklių tipų FQN)", + "export-failed-error": "Valdiklių rinkinio eksportuoti nepavyko: {{error}}", + "create-new-widgets-bundle": "Sukurti naują valdiklių rinkinį", + "widgets-bundle-file": "Valdiklių rinkinio failas", + "invalid-widgets-bundle-file-error": "Valdiklių rinkinio importuoti nepavyko: neteisinga duomenš struktūra.", + "search": "Valdiklių rinkinių paieška", + "selected-widgets-bundles": "Pasirinkta { count, plural, =1 {1 valdiklių rinkinys} other {# valdiklių rinkiniai} }", + "open-widgets-bundle": "Atverti valdiklių rinkinį", + "loading-widgets-bundles": "Įkeliamas valdiklių rinkinys..." + }, + "widget-config": { + "data": "Duomenys", + "settings": "Nustatymai", + "advanced": "Išplėstiniai nustatymai", + "appearance": "Išvaizda", + "widget-card": "Valdiklio kortelė", + "mobile": "Mobilusis vaizdas", + "title": "Pavadinimas", + "title-tooltip": "Pavadinimo paaiškinimas", + "general-settings": "Pagrindiniai nustatymai", + "display-title": "Rodyti valdiklio pavadinimą", + "card-title": "Kortelės pavadinimas", + "drop-shadow": "Šešėlis", + "enable-fullscreen": "Įjungti rodymą per visą ekraną", + "enable-data-export": "Įjungti duomenų eksportą", + "data-export": "Duomenų eksportas", + "background-color": "Fono spalva", + "text-color": "Teksto spalva", + "border-radius": "Rėmelio storis", + "padding": "Padding", + "margin": "Margin", + "widget-style": "Valdiklio stilius", + "widget-css": "Valdiklio CSS", + "title-style": "Antraštės stilius", + "mobile-mode-settings": "Mobiliojo vaizdo režimas", + "order": "Rikiavimo tvarka", + "height": "Aukštis", + "mobile-hide": "Valdiklį paslėpti mobiliojo vaizdo režime", + "desktop-hide": "Slėpti valdiklį darbalaukio režime", + "units": "Papildomas simbolis šalia reikšmės", + "units-by-default": "Numatytieji matavimo vienetai", + "decimals": "Skaimenys po kablelio", + "decimals-by-default": "Dešimtainiai skaičiai pagal numatytuosius nustatymus", + "default-data-key-parameter-hint": "Šis parametras taikomas visoms valdiklių reikšmėms, nebent jis nustatomas duomenų rakto konfigūracijoje", + "units-short": "Vienetai", + "decimals-short": "Dešimtainiai skaičiai", + "decimals-suffix": "dešimtainiai skaičiai", + "timewindow": "Laikotarpio langas", + "use-dashboard-timewindow": "Naudoti skydelio laikotarpio langą", + "use-widget-timewindow": "Naudoti valdiklio laikotarpio langą", + "display-timewindow": "Rodyti laikotarpio langą", + "legend": "Legenda", + "display-legend": "Rodyti legendą", + "datasources": "Duomenų šaltiniai", + "datasource": "Duomenų šaltinis", + "maximum-datasources": "Daugiausiai leidžiama { count, plural, =1 {1 vienas duomenų šaltinis.} other {# duomenų šaltiniai} }", + "timeseries-key-error": "Reikia nurodyti nors vieną telemetrijos rodiklį", + "datasource-type": "Tipas", + "datasource-parameters": "Parametrai", + "remove-datasource": "Pašalinti duomenų šaltinį", + "add-datasource": "Pridėti duomenų šaltinį", + "target-device": "Tikslinis įrenginys", + "alarm-source": "Įspėjimo šaltinis", + "actions": "Veiksmai", + "action": "Veiksmas", + "add-action": "Pridėti veiksmą", + "search-actions": "Veiksmų paieška", + "no-actions-text": "Veiksmų nėra", + "action-source": "Veiksmo šaltinis", + "action-source-required": "Veiksmo šaltinis būtinas.", + "action-name": "Pavadinimas", + "action-name-required": "Veiksmo pavadinimas būtinas.", + "action-name-not-unique": "Veiksmas su tokiu pavadinimu jau yra.
Šaltinyje veiksmų pavadinimai kartotis negali.", + "action-icon": "Piktograma", + "show-hide-action-using-function": "Rodyti/slėpti veiksmą funkcijos pagalba", + "action-type": "Tipas", + "action-type-required": "Veiksmo tipas būtinas.", + "edit-action": "Redaguoti veiksmą", + "delete-action": "Panaikinti veiksmą", + "delete-action-title": "Panaikinti valdiklio veiksmą", + "delete-action-text": "Ar tikrai norite panaikinti valdiklio veiksmą '{{actionName}}'?", + "title-icon": "Antraštės piktograma", + "display-icon": "Rodyti antraštės piktoramą", + "card-icon": "Kortelės piktograma", + "icon-color": "Piktogramos spalva", + "icon-size": "Piktogramos dydis", + "advanced-settings": "Išplėstiniai nustatymai", + "data-settings": "Duomenų nustatymai", + "limits": "Ribos", + "no-data-display-message": "\"Nėra duomenų\" alternatyvus tekstas", + "data-page-size": "Maximum entities per datasource", + "settings-component-not-found": "Settings form component not found for selector '{{selector}}'", + "preview": "Preview", + "set": "Set", + "set-message": "Set message", + "advanced-title-style": "Advanced title style", + "card-style": "Card style", + "text": "Text", + "background": "Background", + "advanced-widget-style": "Advanced widget style", + "card-buttons": "Card buttons", + "show-card-buttons": "Show card buttons", + "card-border-radius": "Card border radius", + "card-appearance": "Card appearance", + "color": "Color" + }, + "widget-type": { + "import": "Importuoti valdiklį", + "export": "Eksportuoti valdiklį", + "export-widget-types": "Eksportuoti valdiklius", + "export-failed-error": "Valdiklio eksportuoti nepavyko: {{error}}", + "create-new-widget-type": "Sukurt naują valdiklį", + "widget-type-file": "Valdiklių failas", + "invalid-widget-type-file-error": "Nepavyko importuoti valdiklio: neteisinga valdiklio duomenų struktūra." + }, + "self-registration": { + "self-registration": "Self Registration", + "self-registration-url": "Self Registration URL", + "captcha-version": "reCAPTCHA version", + "captcha-action": "reCAPTCHA log action name", + "captcha-site-key": "reCAPTCHA site key", + "captcha-secret-key": "reCAPTCHA secret key", + "notification-email": "El. pašto adresas pranešimų siuntimui", + "notification-email-invalid": "Neteisingas el. pašto adreso formatas.", + "notification-email-required": "El. pašto adresas pranešimų siuntimui būtinas.", + "privacy-policy-text": "Privatumo politikos tekstas", + "terms-of-use-text": "Terms of Use text", + "text-message-page": "Tekstinė žinutė registracijos puslapiui", + "enable-mobile-self-registration": "Enable self registration from mobile application", + "mobile-self-registration-title": "Mobile application self registration settings", + "mobile-package": "Application package", + "mobile-package-placeholder": "Ex.: my.example.app", + "mobile-package-hint": "For Android: your own unique Application ID. For iOS: Product bundle identifier.", + "mobile-package-required": "Application package is required", + "mobile-app-secret": "Application secret", + "invalid-mobile-app-secret": "Application secret must contain only alphanumeric characters and must be between 16 and 2048 characters long.", + "copy-mobile-app-secret": "Copy application secret", + "mobile-app-scheme": "Application URL scheme", + "mobile-app-scheme-placeholder": "Ex.: customscheme", + "mobile-app-scheme-required": "Application URL scheme is required", + "mobile-app-host": "Application URL hostname", + "mobile-app-host-placeholder": "Ex.: my.app.host", + "mobile-app-host-required": "Application URL hostname is required", + "show-privacy-policy": "Show Privacy Policy", + "show-terms-of-use": "Show Terms of Use", + "domain-settings": "Domain settings", + "general-settings": "General settings" + }, + "solution-template": { + "solution-template": "Solution template", + "solution-templates": "Solution templates", + "management": "Manage solution templates", + "details": "Details", + "install": "Install", + "level": "Level", + "install-title": "Solution template successfully installed", + "install-failed-title": "Solution template installation failed", + "instructions": "Instructions", + "goto-main-dashboard": "Goto main dashboard", + "delete": "Delete", + "delete-solution-title": "Are you sure you want to delete the solution '{{solutionTitle}}'?", + "delete-solution-text": "Be careful, after the confirmation the solution and all related data will become unrecoverable.", + "installing": "Installing solution template..." + }, + "markdown": { + "edit": "Edit", + "preview": "Preview", + "copy-code": "Click to copy", + "copied": "Copied!" + }, + "white-labeling": { + "white-labeling": "Tinkinimas", + "white-labeling-general": "General White Labeling", + "login-white-labeling": "Prisijungimo puslapio tinkinimas", + "general": "General", + "login": "Prisijungti", + "preview": "Peržiūra", + "app-title": "Aplikaciijios pavadinimas", + "favicon": "Aplikacijos piktograma", + "favicon-description": "*.ico, *.gif ar *.png paveiksliukas, kurio maksimalus dydis {{kbSize}} kilobaitai.", + "favicon-size-error": "Paveiksliukas per didelis. Maksimalus leistinas dydis yra {{kbSize}} kilobaitai.", + "favicon-type-error": "Netinkamas paveiksliuko formatas. Leidžiami tik ICO, GIF ar PNG formatai.", + "drop-favicon-image": "Užvilkite svetainės piktogramos paveiksliuką arba spustelėkite, kad pasirinktumėte failą, kurį norite įkelti.", + "drop-favicon-image-or": "Drag and drop a website icon image or", + "no-favicon-image": "Piktograma nepasirinkta", + "logo": "Logo", + "logo-description": "Bet koks paveiksliukas, kurio dydis neviršija {{kbSize}} kilobaitų.", + "logo-size-error": "Logotipas per didelis. Maksimalus leistinas dydis yra {{kbSize}} kilobaitai.", + "logo-type-error": "Netinkamas logotipo failo. Only images are accepted.", + "drop-logo-image": "Užvilkite svetainės logotipo paveiksliuką arba spustelėkite, kad pasirinktumėte failą, kurį norite įkelti.", + "drop-logo-image-or": "Drag and drop a logo image or", + "no-logo-image": "Logotipas nepasirinktas", + "logo-height": "Logotipo aukštis, px", + "primary-palette": "Pirminė paletė", + "accent-palette": "Akcentų paletė", + "customize-palette": "Pritaikyti", + "advanced-css": "Advanced CSS", + "edit-palette": "Redaguoti paletę", + "save-palette": "Išsaugoti paletę", + "primary-background": "Pirminis fonas", + "secondary-background": "Antrinis fonas", + "hue1": "HUE 1", + "hue2": "HUE 2", + "hue3": "HUE 3", + "page-background-color": "Puslapio fono spalva", + "dark-foreground": "Tamsi tema", + "domain-name": "Domeno pavadinimas", + "base-url": "Bazinis URL", + "prohibit-different-url": "Prohibit to use hostname from the client request headers", + "prohibit-different-url-hint": "This setting should be enabled for production environments. May cause security issues when disabled", + "help-link-base-url": "Bazinis pagalbos puslapio url", + "ui-help-base-url": "UI help base url", + "enable-help-links": "Įjungti pagalbos nuorodas", + "error-verification-url": "Domeno pavadinime negali būti '/' ir ':' simbolių. Pavyzdžiui: thingsboard.io", + "show-platform-name-version": "Rodyti platformos pavadinimą ir versiją", + "platform-name": "Platformas pavadinimas", + "platform-version": "Platformos versija", + "version-mask": "{{name}} v.{{version}}", + "position": { + "label": "Platformos pavadinimo ir versijos išdėstymas", + "under-logo": "Po logotipu", + "bottom": "Prisijungimo formos apačioje" + } + }, + "widgets": { + "background": { + "background": "Background", + "background-settings": "Background settings", + "background-type-image": "Upload image", + "background-type-image-url": "Image URL", + "background-type-color": "Solid color", + "image-url": "Image URL", + "overlay": "Overlay", + "enable-overlay": "Enable overlay", + "blur": "Blur", + "preview": "Preview" + }, + "battery-level": { + "layout": "Layout", + "layout-vertical-solid": "Vertical. Solid", + "layout-horizontal-solid": "Horizontal. Solid", + "layout-vertical-divided": "Vertical. Divided", + "layout-horizontal-divided": "Horizontal. Divided", + "icon": "Icon", + "value": "Value", + "auto-scale": "Auto scale", + "battery-level-color": "Battery level color", + "battery-shape-color": "Battery shape color", + "battery-level-card-style": "Battery level card style" + }, + "chart": { + "common-settings": "Common settings", + "enable-stacking-mode": "Enable stacking mode", + "selection": "Time range selection", + "enable-selection-mode": "Enable selection mode", + "line-shadow-size": "Line shadow size", + "display-smooth-lines": "Display smooth (curved) lines", + "default-bar-width": "Default bar width for non-aggregated data (milliseconds)", + "bar-alignment": "Bar alignment", + "bar-alignment-left": "Left", + "bar-alignment-right": "Right", + "bar-alignment-center": "Center", + "default-font": "Default font", + "default-font-size": "Default font size", + "default-font-color": "Default font color", + "thresholds-line-width": "Default line width for all thresholds", + "tooltip-settings": "Tooltip settings", + "tooltip": "Tooltip", + "show-tooltip": "Show tooltip", + "hover-individual-points": "Hover individual points", + "show-cumulative-values": "Show cumulative values in stacking mode", + "hide-zero-false-values": "Hide zero/false values from tooltip", + "tooltip-value-format-function": "Tooltip value format function", + "grid-settings": "Grid settings", + "show-vertical-lines": "Show vertical lines", + "show-horizontal-lines": "Show horizontal lines", + "grid-outline-border-width": "Grid outline/border width (px)", + "primary-color": "Primary color", + "background-color": "Background color", + "ticks-color": "Ticks color", + "xaxis-settings": "X axis settings", + "axis-title": "Axis title", + "xaxis-tick-labels-settings": "X axis tick labels settings", + "show-tick-labels": "Show axis tick labels", + "yaxis-settings": "Y axis settings", + "min-scale-value": "Minimum value on the scale", + "max-scale-value": "Maximum value on the scale", + "yaxis-tick-labels-settings": "Y axis tick labels settings", + "tick-step-size": "Step size between ticks", + "number-of-decimals": "The number of decimals to display", + "ticks-formatter-function": "Ticks formatter function", + "comparison-settings": "Comparison settings", + "enable-comparison": "Enable comparison", + "time-for-comparison": "Comparison period", + "time-for-comparison-previous-interval": "Previous interval (default)", + "time-for-comparison-days": "Day ago", + "time-for-comparison-weeks": "Week ago", + "time-for-comparison-months": "Month ago", + "time-for-comparison-years": "Year ago", + "time-for-comparison-custom-interval": "Custom interval", + "custom-interval-value": "Custom interval value (ms)", + "comparison-x-axis-settings": "Comparison X axis settings", + "axis-position": "Axis position", + "axis-position-top": "Top (default)", + "axis-position-bottom": "Bottom", + "custom-legend-settings": "Custom legend settings", + "enable-custom-legend": "Enable custom legend (this will allow you to use attribute/timeseries values in key labels)", + "key-name": "Key name", + "key-name-required": "Key name is required", + "key-type": "Key type", + "key-type-attribute": "Attribute", + "key-type-timeseries": "Timeseries", + "label-keys-list": "Keys list to use in labels", + "no-label-keys": "No keys configured", + "add-label-key": "Add new key", + "line-width": "Line width", + "color": "Color", + "data-is-hidden-by-default": "Data is hidden by default", + "disable-data-hiding": "Disable data hiding", + "remove-from-legend": "Remove datakey from legend", + "exclude-from-stacking": "Exclude from stacking(available in \"Stacking\" mode)", + "line-settings": "Line settings", + "show-line": "Show line", + "fill-line": "Fill line", + "fill-line-opacity": "Fill opacity", + "points-settings": "Points settings", + "show-points": "Show points", + "points-line-width": "Line width of points", + "points-radius": "Radius of points", + "point-shape": "Point shape", + "point-shape-circle": "Circle", + "point-shape-cross": "Cross", + "point-shape-diamond": "Diamond", + "point-shape-square": "Square", + "point-shape-triangle": "Triangle", + "point-shape-custom": "Custom function", + "point-shape-draw-function": "Point shape draw function", + "show-separate-axis": "Show separate axis", + "axis-position-left": "Left", + "axis-position-right": "Right", + "thresholds": "Thresholds", + "no-thresholds": "No thresholds configured", + "add-threshold": "Add threshold", + "show-values-for-comparison": "Show historical values for comparison", + "comparison-values-label": "Historical values label", + "comparison-line-color": "Comparison line color", + "threshold-settings": "Threshold settings", + "use-as-threshold": "Use key value as threshold", + "threshold-line-width": "Threshold line width", + "threshold-color": "Threshold color", + "common-pie-settings": "Common pie settings", + "radius": "Radius", + "inner-radius": "Inner radius", + "tilt": "Tilt", + "common-pie-settings-range-error": "Value should be in range from 0 to 1", + "stroke-settings": "Stroke settings", + "width-pixels": "Width (pixels)", + "show-labels": "Show labels", + "animation-settings": "Animation settings", + "animated-pie": "Enable pie animation (experimental)", + "border-settings": "Border settings", + "border-width": "Border width", + "border-color": "Border color", + "legend-settings": "Legend settings", + "display-legend": "Display legend", + "labels-font-color": "Labels font color", + "series": "Series", + "add-series": "Add series", + "series-settings": "Series settings", + "remove-series": "Remove series", + "no-series": "No series configured", + "no-series-error": "At least one series should be specified", + "chart-appearance": "Chart appearance", + "vertical-grid-lines": "Vertical grid lines", + "horizontal-grid-lines": "Horizontal grid lines", + "chart-background": "Chart background", + "grid-lines-color": "Grid lines color", + "border": "Border", + "axis": "Axis", + "vertical-axis": "Vertical axis", + "ticks": "Ticks", + "horizontal-axis": "Horizontal axis" + }, + "color": { + "color-settings": "Color settings", + "color-type-constant": "Constant", + "color-type-range": "Range", + "color-type-function": "Function", + "color": "Color", + "value-range": "Value range", + "from": "From", + "to": "To", + "color-function": "Color function" + }, + "dashboard-state": { + "dashboard-state-settings": "Dashboard state settings", + "dashboard-state": "Dashboard state id", + "autofill-state-layout": "Autofill state layout height by default", + "default-margin": "Default widgets margin", + "default-background-color": "Default background color", + "sync-parent-state-params": "Sync state params with parent dashboard" + }, + "date-range-navigator": { + "date-range-picker-settings": "Date range picker settings", + "hide-date-range-picker": "Hide date range picker", + "picker-one-panel": "Date range picker one panel", + "picker-auto-confirm": "Date range picker auto confirm", + "picker-show-template": "Date range picker show template", + "first-day-of-week": "First day of the week", + "interval-settings": "Interval settings", + "hide-interval": "Hide interval", + "initial-interval": "Initial interval", + "interval-hour": "Hour", + "interval-day": "Day", + "interval-week": "Week", + "interval-two-weeks": "2 weeks", + "interval-month": "Month", + "interval-three-months": "3 months", + "interval-six-months": "6 months", + "step-settings": "Step settings", + "hide-step-size": "Hide step size", + "initial-step-size": "Initial step size", + "hide-labels": "Hide labels", + "use-session-storage": "Use session storage", + "localizationMap": { + "Sun": "Sun", + "Mon": "Mon", + "Tue": "Tue", + "Wed": "Wed", + "Thu": "Thu", + "Fri": "Fri", + "Sat": "Sat", + "Jan": "Jan", + "Feb": "Feb", + "Mar": "Mar", + "Apr": "Apr", + "May": "May", + "Jun": "Jun", + "Jul": "Jul", + "Aug": "Aug", + "Sep": "Sep", + "Oct": "Oct", + "Nov": "Nov", + "Dec": "Dec", + "January": "January", + "February": "February", + "March": "March", + "April": "April", + "June": "June", + "July": "July", + "August": "August", + "September": "September", + "October": "October", + "November": "November", + "December": "December", + "Custom Date Range": "Custom Date Range", + "Date Range Template": "Date Range Template", + "Today": "Today", + "Yesterday": "Yesterday", + "This Week": "This Week", + "Last Week": "Last Week", + "This Month": "This Month", + "Last Month": "Last Month", + "Year": "Year", + "This Year": "This Year", + "Last Year": "Last Year", + "Date picker": "Date picker", + "Hour": "Hour", + "Day": "Day", + "Week": "Week", + "2 weeks": "2 Weeks", + "Month": "Month", + "3 months": "3 Months", + "6 months": "6 Months", + "Custom interval": "Custom interval", + "Interval": "Interval", + "Step size": "Step size", + "Ok": "Ok" + } + }, + "entities-hierarchy": { + "hierarchy-data-settings": "Hierarchy data settings", + "relations-query-function": "Node relations query function", + "has-children-function": "Node has children function", + "node-state-settings": "Node state settings", + "node-opened-function": "Default node opened function", + "node-disabled-function": "Node disabled function", + "display-settings": "Display settings", + "node-icon-function": "Node icon function", + "node-text-function": "Node text function", + "sort-settings": "Sort settings", + "nodes-sort-function": "Nodes sort function" + }, + "edge": { + "display-default-title": "Display default title" + }, + "gateway": { + "general-settings": "General settings", + "widget-title": "Widget title", + "default-archive-file-name": "Default archive file name", + "device-type-for-new-gateway": "Device type for new gateway", + "messages-settings": "Messages settings", + "save-config-success-message": "Text message about successfully saved gateway configuration", + "device-name-exists-message": "Text message when device with entered name is already exists", + "gateway-title": "Gateway form", + "read-only": "Read only", + "events-title": "Gateway events form title", + "events-filter": "Events filter", + "event-key-contains": "Event key contains...", + "show-connector": "Show for the connector", + "connector-state-param-key": "Connector state parameter key", + "status": "Status", + "message": "Message", + "created-time": "Created time" + }, + "gauge": { + "default-color": "Default color", + "radial-gauge-settings": "Radial gauge settings", + "ticks-settings": "Ticks settings", + "min-value": "Minimum value", + "max-value": "Maximum value", + "start-ticks-angle": "Start ticks angle", + "ticks-angle": "Ticks angle", + "major-ticks-count": "Major ticks count", + "major-ticks-color": "Major ticks color", + "minor-ticks-count": "Minor ticks count", + "minor-ticks-color": "Minor ticks color", + "tick-numbers-font": "Tick numbers font", + "unit-title-settings": "Unit title settings", + "show-unit-title": "Show unit title", + "unit-title": "Unit title", + "title-font": "Title text font", + "units-settings": "Units settings", + "units-font": "Units text font", + "value-box-settings": "Value box settings", + "show-value-box": "Show value box", + "value-int": "Digits count for integer part of value", + "value-font": "Value text font", + "value-box-rect-stroke-color": "Value box rectangle stroke color", + "value-box-rect-stroke-color-end": "Value box rectangle stroke color - end gradient", + "value-box-background-color": "Value box background color", + "value-box-shadow-color": "Value box shadow color", + "plate-settings": "Plate settings", + "show-plate-border": "Show plate border", + "plate-color": "Plate color", + "needle-settings": "Needle settings", + "needle-circle-size": "Needle circle size", + "needle-color": "Needle color", + "needle-color-end": "Needle color - end gradient", + "needle-color-shadow-up": "Upper half of the needle shadow color", + "needle-color-shadow-down": "Drop shadow needle color", + "highlights-settings": "Highlights settings", + "highlights-width": "Highlights width", + "highlights": "Highlights", + "highlight-from": "From", + "highlight-to": "To", + "highlight-color": "Color", + "no-highlights": "No highlights configured", + "add-highlight": "Add highlight", + "animation-settings": "Animation settings", + "enable-animation": "Enable animation", + "animation-duration": "Animation duration", + "animation-rule": "Animation rule", + "animation-linear": "Linear", + "animation-quad": "Quad", + "animation-quint": "Quint", + "animation-cycle": "Cycle", + "animation-bounce": "Bounce", + "animation-elastic": "Elastic", + "animation-dequad": "Dequad", + "animation-dequint": "Dequint", + "animation-decycle": "Decycle", + "animation-debounce": "Debounce", + "animation-delastic": "Delastic", + "linear-gauge-settings": "Linear gauge settings", + "bar-stroke-width": "Bar stroke width", + "bar-stroke-color": "Bar stroke color", + "bar-background-color": "Gauge bar background color", + "bar-background-color-end": "Bar background color - end gradient", + "progress-bar-color": "Progress bar color", + "progress-bar-color-end": "Progress bar color - end gradient", + "major-ticks-names": "Major ticks names", + "show-stroke-ticks": "Show ticks stroke", + "major-ticks-font": "Major ticks font", + "border-color": "Border color", + "border-width": "Border width", + "needle-circle-color": "Needle circle color", + "animation-target": "Animation target", + "animation-target-needle": "Needle", + "animation-target-plate": "Plate", + "common-settings": "Common gauge settings", + "gauge-type": "Gauge type", + "gauge-type-arc": "Arc", + "gauge-type-donut": "Donut", + "gauge-type-horizontal-bar": "Horizontal bar", + "gauge-type-vertical-bar": "Vertical bar", + "donut-start-angle": "Angle to start from", + "bar-settings": "Gauge bar settings", + "relative-bar-width": "Relative bar width", + "neon-glow-brightness": "Neon glow effect brightness, (0-100), 0 - disable effect", + "stripes-thickness": "Thickness of the stripes, 0 - no stripes", + "rounded-line-cap": "Display rounded line cap", + "bar-color-settings": "Bar color settings", + "use-precise-level-color-values": "Use precise color levels", + "bar-colors": "Bar colors, from lower to upper", + "color": "Color", + "no-bar-colors": "No bar colors configured", + "add-bar-color": "Add bar color", + "from": "From", + "to": "To", + "fixed-level-colors": "Bar colors using boundary values", + "gauge-title-settings": "Gauge title settings", + "show-gauge-title": "Show gauge title", + "gauge-title": "Gauge title", + "gauge-title-font": "Gauge title font", + "unit-title-and-timestamp-settings": "Unit title and timestamp settings", + "show-timestamp": "Show value timestamp", + "timestamp-format": "Timestamp format", + "label-font": "Font of label showing under value", + "value-settings": "Value settings", + "show-value": "Show value text", + "min-max-settings": "Minimum/maximum labels settings", + "show-min-max": "Show min and max values", + "min-max-font": "Font of minimum and maximum labels", + "show-ticks": "Show ticks", + "tick-width": "Tick width", + "tick-color": "Tick color", + "tick-values": "Tick values", + "no-tick-values": "No tick values configured", + "add-tick-value": "Add tick value" + }, + "gpio": { + "pin": "Pin", + "label": "Label", + "row": "Row", + "column": "Column", + "color": "Color", + "panel-settings": "Panel settings", + "background-color": "Background color", + "gpio-switches": "GPIO switches", + "no-gpio-switches": "No GPIO switches configured", + "add-gpio-switch": "Add GPIO switch", + "gpio-status-request": "GPIO status request", + "method-name": "Method name", + "method-body": "Method body", + "gpio-status-change-request": "GPIO status change request", + "parse-gpio-status-function": "Parse gpio status function", + "gpio-leds": "GPIO leds", + "no-gpio-leds": "No GPIO leds configured", + "add-gpio-led": "Add GPIO led" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "Attribute parameter cannot be used in this widget", + "blocked-location": "Geolocation is blocked in your browser", + "claim-device": "Claim device", + "claim-failed": "Failed to claim the device!", + "claim-not-found": "Device not found!", + "claim-successful": "Device was successfully claimed!", + "date": "Date", + "device-name": "Device name", + "device-name-required": "Device name is required", + "discard-changes": "Discard changes", + "entity-attribute-required": "Entity attribute is required", + "entity-coordinate-required": "Both fields, latitude and longitude, are required", + "entity-timeseries-required": "Entity timeseries is required", + "get-location": "Get current location", + "invalid-date": "Invalid Date", + "latitude": "Latitude", + "longitude": "Longitude", + "min-value-error": "Min value is {{value}}", + "max-value-error": "Max value is {{value}}", + "not-allowed-entity": "Selected entity cannot have shared attributes", + "no-attribute-selected": "No attribute is selected", + "no-datakey-selected": "No datakey is selected", + "no-coordinate-specified": "Datakey for latitude/longitude doesn't specified", + "no-entity-selected": "No entity selected", + "no-image": "No image", + "no-support-geolocation": "Your browser doesn't support geolocation", + "no-support-web-camera": "Your browser does not support cameras", + "enable-https-use-widget": "Please enable HTTPS to use this widget", + "no-found-your-camera": "Can't find your camera", + "no-permission-camera": "Permission was denied by the user / This site doesn't have permission to use the camera", + "no-timeseries-selected": "No timeseries selected", + "secret-key": "Secret key", + "secret-key-required": "Secret key is required", + "switch-attribute-value": "Switch entity attribute value", + "switch-camera": "Switch camera", + "switch-timeseries-value": "Switch entity timeseries value", + "take-photo": "Take photo", + "time": "Time", + "timeseries-not-allowed": "Timeseries parameter cannot be used in this widget", + "update-failed": "Update failed", + "update-successful": "Update successful", + "update-attribute": "Update attribute", + "update-timeseries": "Update timeseries", + "value": "Value", + "general-settings": "General settings", + "widget-title": "Widget title", + "claim-button-label": "Claiming button label", + "show-secret-key-field": "Show 'Secret key' input field", + "labels-settings": "Labels settings", + "show-labels": "Show labels", + "device-name-label": "Label for device name input field", + "secret-key-label": "Label for secret key input field", + "messages-settings": "Messages settings", + "claim-device-success-message": "Text message of successful device claiming", + "claim-device-not-found-message": "Text message when device not found", + "claim-device-failed-message": "Text message of failed device claiming", + "claim-device-name-required-message": "'Device name required' error message", + "claim-device-secret-key-required-message": "'Secret key required' error message", + "relations-settings": "Relations settings", + "relate-device-to-state-entity": "Relate device to current state entity", + "relate-direction": "Relate direction", + "relate-direction-from": "From", + "relate-direction-to": "To", + "relation-type": "Relation type", + "show-label": "Show label", + "label": "Label", + "required": "Required", + "required-error-message": "'Required' error message", + "show-result-message": "Show result message", + "integer-field-settings": "Integer field settings", + "min-value": "Min value", + "max-value": "Max value", + "double-field-settings": "Double field settings", + "text-field-settings": "Text field settings", + "min-length": "Min length", + "max-length": "Max length", + "checkbox-settings": "Checkbox settings", + "true-label": "Checked label", + "false-label": "Unchecked label", + "image-input-settings": "Image input settings", + "display-preview": "Display preview", + "display-clear-button": "Display clear button", + "display-apply-button": "Display apply button", + "display-discard-button": "Display discard button", + "datetime-field-settings": "Date/time field settings", + "display-time-input": "Display time input", + "latitude-key-name": "Latitude key name", + "longitude-key-name": "Longitude key name", + "show-get-location-button": "Show button 'Get current location'", + "use-high-accuracy": "Use high accuracy", + "location-fields-settings": "Location fields settings", + "latitude-label": "Label for latitude", + "longitude-label": "Label for longitude", + "input-fields-alignment": "Input fields alignment", + "input-fields-alignment-column": "Column (default)", + "input-fields-alignment-row": "Row", + "layout": "Layout", + "row-gap": "Gap between rows in pixels", + "column-gap": "Gap between columns in pixels", + "latitude-field-required": "Latitude field required", + "longitude-field-required": "Longitude field required", + "attribute-settings": "Attribute settings", + "widget-mode": "Widget mode", + "widget-mode-update-attribute": "Update attribute", + "widget-mode-update-timeseries": "Update timeseries", + "attribute-scope": "Attribute scope", + "attribute-scope-server": "Server attribute", + "attribute-scope-shared": "Shared attribute", + "value-required": "Value required", + "image-settings": "Image settings", + "image-format": "Image format", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "Image quality that use lossy compression such as jpeg and webp", + "max-image-width": "Maximum image width", + "max-image-height": "Maximum image height", + "action-buttons": "Action buttons", + "show-action-buttons": "Show action buttons", + "update-all-values": "Update all values, not only modified", + "save-button-label": "'SAVE' button label", + "reset-button-label": "'UNDO' button label", + "group-settings": "Group settings", + "show-group-title": "Show title for group of fields, related to different entities", + "group-title": "Group title", + "fields-alignment": "Fields alignment", + "fields-alignment-row": "Row (default)", + "fields-alignment-column": "Column", + "fields-in-row": "Number of fields in the row", + "option-value": "Value (write 'null' for create empty option)", + "option-label": "Label", + "hide-input-field": "Hide input field", + "datakey-type": "Datakey type", + "datakey-type-server": "Server attribute (default)", + "datakey-type-shared": "Shared attribute", + "datakey-type-timeseries": "Timeseries", + "datakey-value-type": "Datakey value type", + "datakey-value-type-string": "String", + "datakey-value-type-double": "Double", + "datakey-value-type-integer": "Integer", + "datakey-value-type-json": "JSON", + "datakey-value-type-boolean-checkbox": "Boolean (Checkbox)", + "datakey-value-type-boolean-switch": "Boolean (Switch)", + "datakey-value-type-date-time": "Date & Time", + "datakey-value-type-date": "Date", + "datakey-value-type-time": "Time", + "datakey-value-type-select": "Select", + "datakey-value-type-color": "Color", + "value-is-required": "Value is required", + "ability-to-edit-attribute": "Ability to edit attribute", + "ability-to-edit-attribute-editable": "Editable (default)", + "ability-to-edit-attribute-disabled": "Disabled", + "ability-to-edit-attribute-readonly": "Read-only", + "disable-on-datakey-name": "Disable on false value of another datakey (specify datakey name)", + "field-appearance": "Field appearance", + "appearance-fill": "Fill", + "appearance-outline": "Outline", + "subscript-sizing": "Subscript sizing", + "subscript-sizing-fixed": "Fixed", + "subscript-sizing-dynamic": "Dynamic", + "slide-toggle-settings": "Slide toggle settings", + "slide-toggle-label-position": "Slide toggle label position", + "slide-toggle-label-position-after": "After", + "slide-toggle-label-position-before": "Before", + "select-options": "Select options", + "no-select-options": "No select options configured", + "add-select-option": "Add select option", + "numeric-field-settings": "Numeric field settings", + "step-interval": "Step interval between values", + "error-messages": "Error messages", + "min-value-error-message": "'Min value' error message", + "max-value-error-message": "'Max value' error message", + "invalid-date-error-message": "'Invalid date' error message", + "invalid-JSON-error-message": "'Invalid JSON' error message", + "icon-settings": "Icon settings", + "dialog-editor-settings": "Dialog editor settings", + "use-custom-icon": "Use custom icon", + "input-cell-icon": "Icon to show before input cell", + "value-conversion-settings": "Value conversion settings", + "get-value-settings": "Get value settings", + "use-get-value-function": "Use getValue function", + "get-value-function": "getValue function", + "set-value-settings": "Set value settings", + "use-set-value-function": "Use setValue function", + "set-value-function": "setValue function", + "json-invalid": "JSON value has an invalid format", + "title": "Title", + "cancel-button-label": "'Cancel' button label" + }, + "invalid-qr-code-text": "Invalid input text for QR code. Input should have a string type", + "qr-code": { + "use-qr-code-text-function": "Use QR code text function", + "qr-code-text-pattern": "QR code text pattern (for ex. '${entityName} | ${keyName} - some text.')", + "qr-code-text-pattern-hint": "QR code text pattern use the value of the first found key in the entities in the entity alias.", + "qr-code-text-pattern-required": "QR code text pattern is required.", + "qr-code-text-function": "QR code text function" + }, + "label-widget": { + "label-pattern": "Pattern", + "label-pattern-hint": "Hint: for ex. 'Text ${keyName} units.' or ${#<key index>} units'", + "label-pattern-required": "Pattern is required", + "label-position": "Position (Percentage relative to background)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "Background color", + "font-settings": "Font settings", + "background-image": "Background image", + "labels": "Labels", + "no-labels": "No labels configured", + "add-label": "Add label" + }, + "navigation": { + "title": "Title", + "navigation-path": "Navigation path", + "filter-type": "Filter type", + "filter-type-all": "All items", + "filter-type-include": "Include items", + "filter-type-exclude": "Exclude items", + "items": "Items", + "enter-urls-to-filter": "Enter urls to filter..." + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "Message type", + "method": "Method", + "params": "Params", + "created-time": "Created time", + "expiration-time": "Expiration time", + "retries": "Retries", + "status": "Status", + "filter": "Filter", + "refresh": "Refresh", + "add": "Add RPC request", + "details": "Details", + "delete": "Delete", + "delete-request-title": "Delete Persistent RPC request", + "delete-request-text": "Are you sure you want to delete request?", + "details-title": "Details RPC ID: ", + "additional-info": "Additional info", + "response": "Response", + "any-status": "Any status", + "rpc-status-list": "RPC status list", + "no-request-prompt": "No request to display", + "send-request": "Send request", + "add-title": "Create Persistent RPC request", + "method-error": "Method is required.", + "timeout-error": "Min timeout value is 5000 (5 seconds).", + "white-space-error": "White space is not allowed.", + "rpc-status": { + "QUEUED": "QUEUED", + "SENT": "SENT", + "DELIVERED": "DELIVERED", + "SUCCESSFUL": "SUCCESSFUL", + "TIMEOUT": "TIMEOUT", + "EXPIRED": "EXPIRED", + "FAILED": "FAILED" + }, + "rpc-search-status-all": "ALL", + "message-types": { + "false": "Two-way", + "true": "One-way" + }, + "general-settings": "General settings", + "enable-filter": "Enable filter", + "enable-sticky-header": "Display header while scrolling", + "enable-sticky-action": "Display actions column while scrolling", + "display-request-details": "Display request details", + "allow-send-request": "Allow send RPC request", + "allow-delete-request": "Allow delete request", + "columns-settings": "Columns settings", + "display-columns": "Columns to display", + "column": "Column", + "no-columns-found": "No columns found", + "no-columns-matching": "'{{column}}' not found." + }, + "rpc": { + "value-settings": "Value settings", + "initial-value": "Initial value", + "retrieve-value-settings": "Retrieve on/off value settings", + "retrieve-value-method": "Retrieve value using method", + "retrieve-value-method-none": "Don't retrieve", + "retrieve-value-method-rpc": "Call RPC get value method", + "retrieve-value-method-attribute": "Subscribe for attribute", + "retrieve-value-method-timeseries": "Subscribe for timeseries", + "attribute-value-key": "Attribute key", + "timeseries-value-key": "Timeseries key", + "get-value-method": "RPC get value method", + "parse-value-function": "Parse value function", + "update-value-settings": "Update value settings", + "set-value-method": "RPC set value method", + "convert-value-function": "Convert value function", + "rpc-settings": "RPC settings", + "request-timeout": "RPC request timeout (ms)", + "persistent-rpc-settings": "Persistent RPC settings", + "request-persistent": "RPC request persistent", + "persistent-polling-interval": "Polling interval (ms) to get persistent RPC command response", + "common-settings": "Common settings", + "switch-title": "Switch title", + "show-on-off-labels": "Show on/off labels", + "slide-toggle-label": "Slide toggle label", + "label-position": "Label position", + "label-position-before": "Before", + "label-position-after": "After", + "slider-color": "Slider color", + "slider-color-primary": "Primary", + "slider-color-accent": "Accent", + "slider-color-warn": "Warn", + "button-style": "Button style", + "button-raised": "Raised button", + "button-primary": "Primary color", + "button-background-color": "Button background color", + "button-text-color": "Button text color", + "widget-title": "Widget title", + "button-label": "Button label", + "device-attribute-scope": "Device attribute scope", + "server-attribute": "Server attribute", + "shared-attribute": "Shared attribute", + "device-attribute-parameters": "Device attribute parameters", + "is-one-way-command": "Is one way command", + "rpc-method": "RPC method", + "rpc-method-params": "RPC method params", + "show-rpc-error": "Show RPC command execution error", + "led-title": "LED title", + "led-color": "LED color", + "check-status-settings": "Check status settings", + "perform-rpc-status-check": "Perform RPC device status check", + "retrieve-led-status-value-method": "Retrieve led status value using method", + "led-status-value-attribute": "Device attribute containing led status value", + "led-status-value-timeseries": "Device timeseries containing led status value", + "check-status-method": "RPC check device status method", + "parse-led-status-value-function": "Parse led status value function", + "knob-title": "Knob title", + "min-value": "Minimum value", + "max-value": "Maximum value" + }, + "maps": { + "select-entity": "Select entity", + "select-entity-hint": "Hint: after selection click at the map to set position", + "tooltips": { + "placeMarker": "Click to place '{{entityName}}' entity", + "firstVertex": "Polygon for '{{entityName}}': click to place first point", + "firstVertex-cut": "Click to place first point", + "continueLine": "Polygon for '{{entityName}}': click to continue drawing", + "continueLine-cut": "Click to continue drawing", + "finishLine": "Click any existing marker to finish", + "finishPoly": "Polygon for '{{entityName}}': click first marker to finish and save", + "finishPoly-cut": "Click first marker to finish and save", + "finishRect": "Polygon for '{{entityName}}': click to finish and save", + "startCircle": "Circle for '{{entityName}}': click to place circle center", + "finishCircle": "Circle for '{{entityName}}': click to finish circle", + "placeCircleMarker": "Click to place circle marker" + }, + "actions": { + "finish": "Finish", + "cancel": "Cancel", + "removeLastVertex": "Remove last point" + }, + "buttonTitles": { + "drawMarkerButton": "Place entity", + "drawPolyButton": "Create polygon", + "drawLineButton": "Create polyline", + "drawCircleButton": "Create circle", + "drawRectButton": "Create rectangle", + "editButton": "Edit mode", + "dragButton": "Drag-drop mode", + "cutButton": "Cut polygon area", + "deleteButton": "Remove", + "drawCircleMarkerButton": "Create circle marker", + "rotateButton": "Rotate polygon" + }, + "map-provider-settings": "Map provider settings", + "map-provider": "Map provider", + "map-provider-google": "Google maps", + "map-provider-openstreet": "OpenStreet maps", + "map-provider-here": "HERE maps", + "map-provider-image": "Image map", + "map-provider-tencent": "Tencent maps", + "openstreet-provider": "OpenStreet map provider", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (Default)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-esri-imagery": "Esri.WorldImagery", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "Use custom provider", + "custom-provider-tile-url": "Custom provider tile URL", + "google-maps-api-key": "Google Maps API Key", + "default-map-type": "Default map type", + "google-map-type-roadmap": "Roadmap", + "google-map-type-satelite": "Satellite", + "google-map-type-hybrid": "Hybrid", + "google-map-type-terrain": "Terrain", + "map-layer": "Map layer", + "here-map-normal-day": "HERE.normalDay (Default)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "Credentials", + "here-app-id": "HERE app id", + "here-app-code": "HERE app code", + "here-api-key": "HERE API key", + "here-use-new-version-api-3": "Use API version 3", + "tencent-maps-api-key": "Tencent Maps API Key", + "tencent-map-type-roadmap": "Roadmap", + "tencent-map-type-satelite": "Satellite", + "tencent-map-type-hybrid": "Hybrid", + "image-map-background": "Image map background", + "image-map-background-from-entity-attribute": "Take image map background from entity attribute", + "image-url-source-entity-alias": "Image URL source entity alias", + "image-url-source-entity-attribute": "Image URL source entity attribute", + "common-map-settings": "Common map settings", + "x-pos-key-name": "X position key name", + "y-pos-key-name": "Y position key name", + "latitude-key-name": "Latitude key name", + "longitude-key-name": "Longitude key name", + "default-map-zoom-level": "Default map zoom level (0 - 20)", + "default-map-center-position": "Default map center position (0,0)", + "disable-scroll-zooming": "Disable scroll zooming", + "disable-double-click-zooming": "Disable double click zooming", + "disable-zoom-control-buttons": "Disable zoom control buttons", + "fit-map-bounds": "Fit map bounds to cover all markers", + "use-default-map-center-position": "Use default map center position", + "entities-limit": "Limit of entities to load", + "markers-settings": "Markers settings", + "marker-offset-x": "Marker X offset relative to position multiplied by marker width", + "marker-offset-y": "Marker Y offset relative to position multiplied by marker height", + "position-function": "Position conversion function, should return x,y coordinates as double from 0 to 1 each", + "draggable-marker": "Draggable marker", + "label": "Label", + "show-label": "Show label", + "use-label-function": "Use label function", + "label-pattern": "Label (pattern examples: '${entityName}', '${entityName}: (Text ${keyName} units.)' )", + "label-function": "Label function", + "tooltip": "Tooltip", + "show-tooltip": "Show tooltip", + "show-tooltip-action": "Action for displaying the tooltip", + "show-tooltip-action-click": "Show tooltip on click (Default)", + "show-tooltip-action-hover": "Show tooltip on hover", + "auto-close-tooltips": "Auto-close tooltips", + "use-tooltip-function": "Use tooltip function", + "tooltip-pattern": "Tooltip (for ex. 'Text ${keyName} units.' or Link text')", + "tooltip-function": "Tooltip function", + "tooltip-offset-x": "Tooltip X offset relative to marker anchor multiplied by marker width", + "tooltip-offset-y": "Tooltip Y offset relative to marker anchor multiplied by marker height", + "color": "Color", + "use-color-function": "Use color function", + "color-function": "Color function", + "marker-image": "Marker image", + "use-marker-image-function": "Use marker image function", + "custom-marker-image": "Custom marker image", + "custom-marker-image-size": "Custom marker image size (px)", + "marker-image-function": "Marker image function", + "marker-images": "Marker images", + "polygon-settings": "Polygon settings", + "show-polygon": "Show polygon", + "polygon-key-name": "Polygon key name", + "enable-polygon-edit": "Enable polygon edit", + "polygon-label": "Polygon label", + "show-polygon-label": "Show polygon label", + "use-polygon-label-function": "Use polygon label function", + "polygon-label-pattern": "Polygon label (pattern examples: '${entityName}', '${entityName}: (Text ${keyName} units.)' )", + "polygon-label-function": "Polygon label function", + "polygon-tooltip": "Polygon tooltip", + "show-polygon-tooltip": "Show polygon tooltip", + "auto-close-polygon-tooltips": "Auto-close polygon tooltips", + "use-polygon-tooltip-function": "Use polygon tooltip function", + "polygon-tooltip-pattern": "Tooltip (for ex. 'Text ${keyName} units.' or Link text')", + "polygon-tooltip-function": "Polygon tooltip function", + "polygon-color": "Polygon color", + "polygon-opacity": "Polygon opacity", + "use-polygon-color-function": "Use polygon color function", + "polygon-color-function": "Polygon color function", + "polygon-stroke": "Polygon stroke", + "stroke-color": "Stroke color", + "stroke-opacity": "Stroke opacity", + "stroke-weight": "Stroke weight", + "use-polygon-stroke-color-function": "Use polygon stroke color function", + "polygon-stroke-color-function": "Polygon stroke color function", + "circle-settings": "Circle settings", + "show-circle": "Show circle", + "circle-key-name": "Circle key name", + "enable-circle-edit": "Enable circle edit", + "circle-label": "Circle label", + "show-circle-label": "Show circle label", + "use-circle-label-function": "Use circle label function", + "circle-label-pattern": "Circle label (pattern examples: '${entityName}', '${entityName}: (Text ${keyName} units.)' )", + "circle-label-function": "Circle label function", + "circle-tooltip": "Circle tooltip", + "show-circle-tooltip": "Show circle tooltip", + "auto-close-circle-tooltips": "Auto-close circle tooltips", + "use-circle-tooltip-function": "Use circle tooltip function", + "circle-tooltip-pattern": "Tooltip (for ex. 'Text ${keyName} units.' or Link text')", + "circle-tooltip-function": "Circle tooltip function", + "circle-fill-color": "Circle fill color", + "circle-fill-color-opacity": "Circle fill color opacity", + "use-circle-fill-color-function": "Use circle fill color function", + "circle-fill-color-function": "Circle fill color function", + "circle-stroke": "Circle stroke", + "use-circle-stroke-color-function": "Use circle stroke color function", + "circle-stroke-color-function": "Circle stroke color function", + "markers-clustering-settings": "Markers clustering settings", + "use-map-markers-clustering": "Use map markers clustering", + "zoom-on-cluster-click": "Zoom when clicking on a cluster", + "max-cluster-zoom": "The maximum zoom level when a marker can be part of a cluster (0 - 18)", + "max-cluster-radius-pixels": "Maximum radius that a cluster will cover in pixels", + "cluster-zoom-animation": "Show animation on markers when zooming", + "show-markers-bounds-on-cluster-mouse-over": "Show the bounds of markers when mouse over a cluster", + "spiderfy-max-zoom-level": "Spiderfy at the max zoom level (to see all cluster markers)", + "load-optimization": "Load optimization", + "cluster-chunked-loading": "Use chunks for adding markers so that the page does not freeze", + "cluster-markers-lazy-load": "Use lazy load for adding markers", + "editor-settings": "Editor settings", + "enable-snapping": "Enable snapping to other vertices for precision drawing", + "init-draggable-mode": "Initialize map in draggable mode", + "hide-all-edit-buttons": "Hide all edit control buttons", + "hide-draw-buttons": "Hide draw buttons", + "hide-edit-buttons": "Hide edit buttons", + "hide-remove-button": "Hide remove button", + "route-map-settings": "Route map settings", + "trip-animation-settings": "Trip animation settings", + "normalization-step": "Normalization data step (ms)", + "tooltip-background-color": "Tooltip background color", + "tooltip-font-color": "Tooltip font color", + "tooltip-opacity": "Tooltip opacity (0-1)", + "auto-close-tooltip": "Auto-close tooltip", + "rotation-angle": "Set additional rotation angle for marker (deg)", + "path-settings": "Path settings", + "path-color": "Path color", + "use-path-color-function": "Use path color function", + "path-color-function": "Path color function", + "path-decorator": "Path decorator", + "use-path-decorator": "Use path decorator", + "decorator-symbol": "Decorator symbol", + "decorator-symbol-arrow-head": "Arrow", + "decorator-symbol-dash": "Dash", + "decorator-symbol-size": "Decorator symbol size (px)", + "use-path-decorator-custom-color": "Use path decorator custom color", + "decorator-custom-color": "Decorator custom color", + "decorator-offset": "Decorator offset", + "end-decorator-offset": "End decorator offset", + "decorator-repeat": "Decorator repeat", + "points-settings": "Points settings", + "show-points": "Show points", + "point-color": "Point color", + "point-size": "Point size (px)", + "use-point-color-function": "Use point color function", + "point-color-function": "Point color function", + "use-point-as-anchor": "Use point as anchor", + "point-as-anchor-function": "Point as anchor function", + "independent-point-tooltip": "Independent point tooltip", + "clustering-markers": "Clustering markers", + "use-icon-create-function": "Use markers colour function", + "marker-color-function": "Marker color function" + }, + "markdown": { + "use-markdown-text-function": "Use markdown/HTML value function", + "markdown-text-function": "Markdown/HTML value function", + "markdown-text-pattern": "Markdown/HTML pattern (markdown or HTML with variables, for ex. '${entityName} or ${keyName} - some text.')", + "apply-default-markdown-style": "Apply default markdown style", + "markdown-css": "Markdown/HTML CSS" + }, + "simple-card": { + "label": "Label", + "label-position": "Label position", + "label-position-left": "Left", + "label-position-top": "Top" + }, + "value-card": { + "layout": "Layout", + "layout-square": "Square", + "layout-vertical": "Vertical", + "layout-centered": "Centered", + "layout-simplified": "Simplified", + "layout-horizontal": "Horizontal", + "layout-horizontal-reversed": "Horizontal reversed", + "label": "Label", + "icon": "Icon", + "value": "Value", + "date": "Date", + "value-card-style": "Value card style" + }, + "aggregated-value-card": { + "subtitle": "Subtitle", + "chart": "Chart", + "values": "Values", + "value-appearance": "Value appearance", + "position": "Position", + "position-center": "Center", + "position-right-top": "Right top", + "position-right-bottom": "Right bottom", + "position-left-top": "Left top", + "position-left-bottom": "Left bottom", + "font": "Font", + "color": "Color", + "arrow": "Arrow", + "display-up-down-arrow": "Display Up/Down arrow", + "add-value": "Add value", + "remove-value": "Remove value", + "no-values": "No values configured", + "aggregation": "Aggregation", + "aggregated-value-card-style": "Aggregated value card style" + }, + "alarm-count": { + "alarm-count-card-style": "Alarm count card style" + }, + "entity-count": { + "entity-count-card-style": "Entity count card style" + }, + "count": { + "layout": "Layout", + "layout-column": "Column", + "layout-row": "Row", + "label": "Label", + "icon": "Icon", + "icon-background": "Icon background", + "value": "Value", + "chevron": "Chevron" + }, + "table": { + "common-table-settings": "Common Table Settings", + "enable-search": "Enable search", + "enable-sticky-header": "Always display header", + "enable-sticky-action": "Always display actions column", + "hidden-cell-button-display-mode": "Hidden cell button actions display mode", + "show-empty-space-hidden-action": "Show empty space instead of hidden cell button action", + "dont-reserve-space-hidden-action": "Don't reserve space for hidden action buttons", + "display-timestamp": "Display timestamp column", + "display-milliseconds": "Display timestamp milliseconds", + "display-pagination": "Display pagination", + "default-page-size": "Default page size", + "use-entity-label-tab-name": "Use entity label in tab name", + "hide-empty-lines": "Hide empty lines", + "row-style": "Row style", + "use-row-style-function": "Use row style function", + "row-style-function": "Row style function", + "cell-style": "Cell style", + "use-cell-style-function": "Use cell style function", + "cell-style-function": "Cell style function", + "cell-content": "Cell content", + "use-cell-content-function": "Use cell content function", + "cell-content-function": "Cell content function", + "show-latest-data-column": "Show latest data column", + "latest-data-column-order": "Latest data column order", + "entities-table-title": "Entities table title", + "enable-select-column-display": "Enable select columns to display", + "display-entity-name": "Display entity name column", + "entity-name-column-title": "Entity name column title", + "display-entity-label": "Display entity label column", + "entity-label-column-title": "Entity label column title", + "display-entity-type": "Display entity type column", + "default-sort-order": "Default sort order", + "custom-title": "Custom header title", + "column-width": "Column width (px or %)", + "default-column-visibility": "Default column visibility", + "column-visibility-visible": "Visible", + "column-visibility-hidden": "Hidden", + "column-visibility-hidden-mobile": "Hidden in mobile mode", + "column-selection-to-display": "Column selection in 'Columns to Display'", + "column-selection-to-display-enabled": "Enabled", + "column-selection-to-display-disabled": "Disabled", + "column-export-option": "Include column in export", + "column-export-option-always": "Always", + "column-export-option-only-visible": "Only if column visible", + "column-export-option-never": "Never", + "alarms-table-title": "Alarms table title", + "enable-alarms-selection": "Enable alarms selection", + "enable-alarms-search": "Enable alarms search", + "enable-alarm-filter": "Enable alarm filter", + "display-alarm-details": "Display alarm details", + "allow-alarms-ack": "Allow alarms acknowledgment", + "allow-alarms-clear": "Allow alarms clear", + "display-alarm-activity": "Display alarm activity", + "allow-alarms-assign": "Allow alarms assignment", + "blob-entities-table-title": "Blob entities table title", + "display-created-time": "Display created time column", + "display-type": "Display type column", + "display-customer": "Display customer column", + "no-data-display-message": "\"No data to display\" alternative message", + "force-default-blob-entity-type": "Force default blob entity type", + "scheduler-events-table-title": "Scheduler events table title", + "force-default-event-type": "Force default event type", + "columns": "Columns", + "column-settings": "Column settings", + "remove-column": "Remove column", + "add-column": "Add column", + "no-columns": "No columns configured", + "columns-to-display": "Columns to display", + "table-header": "Table header", + "header-buttons": "Header buttons", + "table-buttons": "Table buttons", + "pagination": "Pagination", + "rows": "Rows", + "timeseries-column-error": "At least one timeseries column should be specified", + "table-tabs": "Table tabs", + "show-cell-actions-menu-mobile": "Show cell actions dropdown menu in mobile mode" + }, + "scheduler": { + "enabled-scheduler-event-views": "Enabled scheduler events views", + "enabled-scheduler-event-views-both": "Both", + "enabled-scheduler-event-views-list": "List View Only", + "enabled-scheduler-event-views-calendar": "Calendar View Only", + "display-name": "Display name", + "type-name": "Type name", + "display-originator-select": "Display originator entity select", + "display-message-type-select": "Display message type select", + "display-metadata-table": "Display message metadata table", + "configuration-html-template": "Configuration HTML template", + "custom-event-types": "Custom event types", + "no-custom-event-types": "No custom event types configured", + "add-custom-event-type": "Add custom event type" + }, + "value-source": { + "value-source": "Value source", + "predefined-value": "Predefined value", + "entity-attribute": "Entity attribute", + "value": "Value", + "source-entity-alias": "Source entity alias", + "source-entity-attribute": "Source entity attribute" + }, + "widget-font": { + "font-settings": "Font settings", + "font-family": "Font family", + "size": "Size", + "relative-font-size": "Relative font size (percents)", + "font-style": "Style", + "font-style-normal": "Normal", + "font-style-italic": "Italic", + "font-style-oblique": "Oblique", + "font-weight": "Weight", + "font-weight-normal": "Normal", + "font-weight-bold": "Bold", + "font-weight-bolder": "Bolder", + "font-weight-lighter": "Lighter", + "color": "Color", + "shadow-color": "Shadow color", + "preview": "Preview", + "line-height": "Line height", + "auto": "Auto" + }, + "home": { + "no-data-available": "No data available" + }, + "system-info": { + "cpu": "CPU", + "ram": "RAM", + "disk": "Disk", + "cpu-warning-text": "Running high on CPU usage. To avoid system failure, optimize system performance.", + "cpu-critical-text": "Critically high CPU usage. To avoid system failure, optimize system performance.", + "ram-warning-text": "Running low on reserve of RAM. To avoid system failure, optimize system performance or increase the size of RAM.", + "ram-critical-text": "Critically low reserve of RAM. To avoid system failure, optimize system performance or increase the size of RAM.", + "disk-warning-text": "Running low on disk space. To avoid data loss, free up or expand the disk space.", + "disk-critical-text": "Critically low disk space. To avoid data loss, free up or expand the disk space." + }, + "cluster-info": { + "service-id": "Service id", + "service-type": "Service type", + "no-data": "No data" + }, + "transport-messages": { + "title": "Transport messages", + "info": "All the messages that came from devices" + }, + "activity": { + "title": "Activity" + }, + "documentation": { + "title": "Documentation", + "add-link": "Add link", + "add-link-title": "Add documentation link", + "name": "Name", + "name-required": "Name is required.", + "link": "Link", + "link-required": "Link is required.", + "columns": "Columns" + }, + "quick-links": { + "title": "Quick links", + "add-link": "Add link", + "add-link-title": "Add quick link", + "quick-link": "Quick link", + "quick-link-required": "Quick link is required.", + "no-links-matching": "No links matching '{{name}}' were found.", + "columns": "Columns" + }, + "recent-dashboards": { + "title": "Dashboards", + "last": "Last viewed", + "starred": "Starred", + "name": "Name", + "last-viewed": "Last viewed", + "no-last-viewed-dashboards": "No last viewed dashboards yet" + }, + "configured-features": { + "title": "Configured features", + "info": "Status of features that require configuration", + "white-labeling-feature": "White labeling", + "email-feature": "Email", + "sms-feature": "SMS", + "slack-feature": "Slack", + "oauth2-feature": "OAuth 2", + "2fa-feature": "2FA", + "feature-configured": "Feature is configured.\nClick to setup", + "feature-not-configured": "Feature is not configured.\nClick to setup" + }, + "version-info": { + "title": "Version", + "contact-us": "Contact us", + "current-version": "Current version", + "current": "Current", + "available-version": "Available version", + "available": "Available", + "upgrade": "Upgrade", + "version-is-up-to-date": "Version is up to date" + }, + "license-info": { + "title": "License info", + "view-all": "View all", + "license-portal": "License portal", + "devices-info": "{count} / { max, plural, =0 {∞} other {#} } Devices", + "assets-info": "{count} / { max, plural, =0 {∞} other {#} } Assets", + "dashboards-info": "{count} / { max, plural, =0 {∞} other {#} } Dashboards", + "integrations-info": "{count} / { max, plural, =0 {∞} other {#} } Integrations", + "community-support": "Community support", + "white-labeling": "White labeling", + "unlimited-datapoints-and-messages": "Unlimited datapoints and messages", + "unlimited-api-calls": "Unlimited API calls" + }, + "usage-info": { + "title": "Usage", + "entities": "Entities", + "api-calls": "API calls" + }, + "functions": { + "title": "Functions", + "pe-feature-tooltip": "Only on ThingsBoard\nProfessional Edition", + "switch-to-pe": "Switch to PE", + "alarms": "Alarms", + "dashboards": "Dashboards", + "entities-and-relations": "Entities & Relations", + "profiles": "Profiles", + "advanced-features": "Advanced features", + "notification-center": "Notification center", + "api-usage": "API usage", + "customers": "Customers", + "customers-hierarchy": "Customers hierarchy", + "roles-and-permissions": "Roles & Permissions", + "groups": "Groups", + "integrations": "Integrations", + "solution-templates": "Solution templates", + "scheduler": "Scheduler", + "white-labeling": "White-labeling" + }, + "devices": { + "view-docs": "View docs", + "inactive": "Inactive", + "active": "Active", + "total": "Total" + }, + "alarms": { + "critical": "Critical", + "assigned-to-me": "Assigned to me", + "total": "Total" + }, + "solution-templates": { + "title": "Solution templates", + "prototype-plan": "Prototype plan", + "startup-plan": "Startup plan" + }, + "getting-started": { + "get-started": "Get started", + "finish": "Finish", + "done-welcome-title": "Welcome on board", + "done-welcome-text": "You did great with it!", + "sys-admin": { + "step1": { + "title": "Create Tenant & Tenant Administrator", + "content": "

A tenant is an individual or an organization that owns or produces devices and assets. The tenant may have multiple tenant administrator users, customers, devices, and assets.

The Tenant Administrator can create and manage devices, assets, customers, and dashboards within the tenant account.

Follow the documentation on how to do it:

", + "how-to-create-tenant": "How to create Tenant & Tenant Administrator" + }, + "step2": { + "title": "Configure feature: Mail server", + "content": "

Mail server configuration is essential for user activation, password recovery, and alarm notification delivery.

Follow the documentation on how to do it:

", + "how-to-configure-mail-server": "How to configure Mail server" + }, + "step3": { + "title": "Configure feature: SMS provider", + "content": "

Configure SMS providers to notify the customers about the alarms via SMS.

Follow the documentation on how to do it:

", + "how-to-configure-sms-provider": "How to configure SMS provider" + }, + "step4": { + "title": "Configure feature: White-labeling", + "content": "

Easily customize your company's or product's logo and color scheme without coding and without restarting the service.

Follow the documentation on how to do it:

", + "how-to-configure-white-labeling": "How to configure White-labeling" + }, + "step5": { + "title": "Configure feature: 2FA", + "content": "

Improve security of the platform accounts with two-factor authentication.

Follow the documentation on how to do it:

", + "how-to-configure-2fa": "How to configure 2FA" + }, + "step6": { + "title": "Configure feature: OAuth 2", + "content": "

Simplify login for the tenant and customer users with Single Sign-On functionality via OAuth 2.0.

Follow the documentation on how to do it:

", + "how-to-configure-oauth2": "How to configure OAuth 2" + }, + "step7": { + "title": "Configure feature: Slack", + "content": "

Distribute notifications to the tenant and customer users via Slack according to the notification rules you set.

Follow the documentation on how to do it:

", + "how-to-configure-notifications": "How to configure Slack" + } + }, + "tenant-admin": { + "step1": { + "title": "Create device", + "content": "

Let's provision your first device to the platform via UI. Follow the documentation on how to do it:

", + "how-to-create-device": "How to create Device" + }, + "step2": { + "title": "Connect device", + "content-before": "

To connect the device you need to get the device credentials. We recommend using default auto-generated credentials which is access token for this guide.

  • Go to device table
  • Click on the device row to open device details
  • Press the button \"Copy access token\"

Use simple commands to publish data over HTTP. Don't forget to replace $ACCESS_TOKEN with your device access token:

", + "ubuntu": { + "install-curl": "Install cURL for Ubuntu:" + }, + "macos": { + "install-curl": "Install cURL for MacOS:" + }, + "windows": { + "install-curl": "Starting Windows 10 b17063, cURL is available by default." + }, + "replace-access-token": "Replace $ACCESS_TOKEN with your device's token:", + "content-after": "

You can also use other protocols such as MQTT, CoAP, etc.

Follow the documentation on how to do it:

", + "how-to-connect-device": "How to connect Device" + }, + "step3": { + "title": "Create dashboard", + "content": "

Create a dashboard to visualize data from entities such as assets, devices, etc.

Follow the documentation on how to do it:

", + "how-to-create-dashboard": "How to create Dashboard" + }, + "step4": { + "title": "Configure alarm rules", + "alarm-rules": "Alarm rules", + "content": "

Let's raise an alarm when the temperature reaches 25°C. Follow the documentation on how to do it:

", + "how-to-configure-alarm-rules": "How to configure Alarm rules" + }, + "step5": { + "title": "Create alarm", + "content-before": "

To trigger the alarm, send a new telemetry value of 26°C or higher.

", + "replace-access-token": "Replace $ACCESS_TOKEN with your device's token:", + "content-after": "

Follow the documentation on how to do it:

", + "how-to-create-alarm": "How to create Alarm" + }, + "step6": { + "title": "Create customer and share dashboard", + "content": "

By creating end-user dashboards, a customer user can only see his own devices, and data from another customer will be hidden.

Follow the documentation on how to do it:

", + "how-to-create-customer-and-share-dashboard": "How to create Customer and share Dashboard" + } + } + } + }, + "icon": { + "icon": "Icon", + "icons": "Icons", + "select-icon": "Select icon", + "material-icons": "Material icons", + "show-all": "Show all icons", + "search-icon": "Search icon", + "no-icons-found": "No icons found for '{{iconSearch}}'" + }, + "subscription": { + "entity-limit-text": "However you can upgrade your subscription plan in order to increase your limits.", + "upgrade-your-plan": "Upgrade subscription plan", + "white-labeling-feature": "White labeling feature", + "white-labeling-text-full": "Rebrand ThingsBoard platform web interface with your company or product logo and color scheme in 2 minutes.

Remove “Powered By” on the dashboards footer.
No coding or service restart required. Allow your customers to white-label their interface as well.", + "enable-white-labeling": "Enable white-labeling feature now by upgrading your subscription plan!", + "read-more": "Read more", + "white-labeling-video-text": "See video tutorial below to see how this feature works!" + }, + "subscription-error": { + "title": "Subscription violation", + "warning-title": "Subscription warning", + "upgrade-subscription-plan": "Please upgrade your subscription plan", + "upgrade-subscription-plan-to-install-solution-template": "In order to install {{solutionTemplateName}} solution you must upgrade your subscription at least to the {{planName}} plan!", + "limit-reached": { + "device-count": "You have reached maximum devices ({{value}}) allowed by your subscription plan!", + "asset-count": "You have reached maximum assets ({{value}}) allowed by your subscription plan!" + }, + "feature-disabled": { + "white-labeling": "White Labeling feature is not allowed by your subscription plan!" + } + }, + "phone-input": { + "phone-input-label": "Phone number", + "phone-input-required": "Phone number is required", + "phone-input-validation": "Phone number is invalid or not possible", + "phone-input-pattern": "Invalid phone number. Should be in E.164 format, ex. {{phoneNumber}}", + "phone-input-hint": "Telefono numeris tarptautiniu formatu , pvz. +37060123456" + }, + "custom": { + "widget-action": { + "action-cell-button": "Action cell button", + "row-click": "On row click", + "polygon-click": "On polygon click", + "marker-click": "On marker click", + "circle-click": "On circle click", + "tooltip-tag-action": "Tooltip tag action", + "node-selected": "On node selected", + "element-click": "On HTML element click", + "pie-slice-click": "On slice click", + "row-double-click": "On row double click", + "card-click": "On card click" + } + }, + "paginator": { + "items-per-page": "Eilučių skaičius puslapyje:", + "first-page-label": "Pirmas puslapis", + "last-page-label": "Paskutinis puslapis", + "next-page-label": "Kitas puslapis", + "previous-page-label": "Ankstesnis puslapis", + "items-per-page-separator": "iš" + } +} + diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/bacnet.json b/ui-ngx/src/assets/metadata/connector-default-configs/bacnet.json new file mode 100644 index 0000000000..a03bbdb975 --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/bacnet.json @@ -0,0 +1,58 @@ +{ + "general": { + "objectName": "TB_gateway", + "address": "0.0.0.0:47808", + "objectIdentifier": 599, + "maxApduLengthAccepted": 1476, + "segmentationSupported": "segmentedBoth", + "vendorIdentifier": 15 + }, + "devices": [ + { + "deviceName": "BACnet Device ${objectName}", + "deviceType": "default", + "address": "192.168.2.110:47808", + "pollPeriod": 10000, + "attributes": [ + { + "key": "temperature", + "type": "string", + "objectId": "analogOutput:1", + "propertyId": "presentValue" + } + ], + "timeseries": [ + { + "key": "state", + "type": "bool", + "objectId": "binaryValue:1", + "propertyId": "presentValue" + } + ], + "attributeUpdates": [ + { + "key": "brightness", + "requestType": "writeProperty", + "objectId": "analogOutput:1", + "propertyId": "presentValue" + } + ], + "serverSideRpc": [ + { + "method": "set_state", + "requestType": "writeProperty", + "requestTimeout": 10000, + "objectId": "binaryOutput:1", + "propertyId": "presentValue" + }, + { + "method": "get_state", + "requestType": "readProperty", + "requestTimeout": 10000, + "objectId": "binaryOutput:1", + "propertyId": "presentValue" + } + ] + } + ] +} \ No newline at end of file diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/ble.json b/ui-ngx/src/assets/metadata/connector-default-configs/ble.json new file mode 100644 index 0000000000..609e742f2e --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/ble.json @@ -0,0 +1,73 @@ +{ + "passiveScanMode": true, + "showMap": false, + "scanner": { + "timeout": 10000, + "deviceName": "Device name" + }, + "devices": [ + { + "name": "Temperature and humidity sensor", + "MACAddress": "4C:65:A8:DF:85:C0", + "pollPeriod": 500000, + "showMap": false, + "timeout": 10000, + "connectRetry": 5, + "connectRetryInSeconds": 0, + "waitAfterConnectRetries": 10, + "telemetry": [ + { + "key": "temperature", + "method": "notify", + "characteristicUUID": "226CAA55-6476-4566-7562-66734470666D", + "valueExpression": "[2]" + }, + { + "key": "humidity", + "method": "notify", + "characteristicUUID": "226CAA55-6476-4566-7562-66734470666D", + "valueExpression": "[0]" + } + ], + "attributes": [ + { + "key": "name", + "method": "read", + "characteristicUUID": "00002A00-0000-1000-8000-00805F9B34FB", + "valueExpression": "[0:2]cm [2:]A" + }, + { + "key": "values", + "method": "read", + "characteristicUUID": "00002A00-0000-1000-8000-00805F9B34FB", + "valueExpression": "All values: [:]" + } + ], + "attributeUpdates": [ + { + "attributeOnThingsBoard": "sharedName", + "characteristicUUID": "00002A00-0000-1000-8000-00805F9B34FB" + } + ], + "serverSideRpc": [ + { + "methodRPC": "rpcMethod1", + "withResponse": true, + "characteristicUUID": "00002A00-0000-1000-8000-00805F9B34FB", + "methodProcessing": "read" + }, + { + "methodRPC": "rpcMethod2", + "withResponse": true, + "characteristicUUID": "00002A00-0000-1000-8000-00805F9B34FB", + "methodProcessing": "write" + }, + { + "methodRPC": "rpcMethod3", + "withResponse": true, + "methodProcessing": "scan" + } + ] + } + ] +} diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/can.json b/ui-ngx/src/assets/metadata/connector-default-configs/can.json new file mode 100644 index 0000000000..ea9d162960 --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/can.json @@ -0,0 +1,89 @@ +{ + "interface": "socketcan", + "channel": "vcan0", + "backend": { + "fd": true + }, + "reconnectPeriod": 5, + "devices": [ + { + "name": "Car", + "sendDataOnlyOnChange": false, + "enableUnknownRpc": true, + "strictEval": false, + "attributes": [ + { + "key": "isDriverDoorOpened", + "nodeId": 41, + "command": "2:2:big:8717", + "value": "4:1:int", + "expression": "bool(value & 0b00000100)", + "polling": { + "type": "once", + "dataInHex": "AB CD AB CD" + } + } + ], + "timeseries": [ + { + "key": "rpm", + "nodeId": 1918, + "isExtendedId": true, + "command": "2:2:big:48059", + "value": "4:2:big:int", + "expression": "value / 4", + "polling": { + "type": "always", + "period": 5, + "dataInHex": "aaaa bbbb aaaa bbbb" + } + }, + { + "key": "milliage", + "nodeId": 1918, + "isExtendedId": true, + "value": "4:2:little:int", + "expression": "value * 10", + "polling": { + "type": "always", + "period": 30, + "dataInHex": "aa bb cc dd ee ff aa bb" + } + } + ], + "attributeUpdates": [ + { + "attributeOnThingsBoard": "softwareVersion", + "nodeId": 64, + "isExtendedId": true, + "dataLength": 4, + "dataExpression": "value + 5", + "dataByteorder": "little" + } + ], + "serverSideRpc": [ + { + "method": "sendSameData", + "nodeId": 4, + "isExtendedId": true, + "isFd": true, + "bitrateSwitch": true, + "dataInHex": "aa bb cc dd ee ff aa bb aa bb cc d ee ff" + }, + { + "method": "setLightLevel", + "nodeId": 5, + "dataLength": 2, + "dataByteorder": "little", + "dataBefore": "00AA" + }, + { + "method": "setSpeed", + "nodeId": 16, + "dataAfter": "0102", + "dataExpression": "userSpeed if maxAllowedSpeed > userSpeed else maxAllowedSpeed" + } + ] + } + ] +} \ No newline at end of file diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/ftp.json b/ui-ngx/src/assets/metadata/connector-default-configs/ftp.json new file mode 100644 index 0000000000..3a193e43da --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/ftp.json @@ -0,0 +1,65 @@ +{ + "host": "0.0.0.0", + "port": 21, + "TLSSupport": false, + "security": { + "type": "basic", + "username": "admin", + "password": "admin" + }, + "paths": [ + { + "devicePatternName": "asd", + "devicePatternType": "Device", + "delimiter": ",", + "path": "fol/*_hello*.txt", + "readMode": "FULL", + "maxFileSize": 5, + "pollPeriod": 500, + "txtFileDataView": "SLICED", + "withSortingFiles": true, + "attributes": [ + { + "key": "temp", + "value": "[1:]" + }, + { + "key": "tmp", + "value": "[0:1]" + } + ], + "timeseries": [ + { + "type": "int", + "key": "[0:1]", + "value": "[0:1]" + }, + { + "type": "int", + "key": "temp", + "value": "[1:]" + } + ] + } + ], + "attributeUpdates": [ + { + "path": "fol/hello.json", + "deviceNameFilter": ".*", + "writingMode": "WRITE", + "valueExpression": "{'${attributeKey}':'${attributeValue}'}" + } + ], + "serverSideRpc": [ + { + "deviceNameFilter": ".*", + "methodFilter": "read", + "valueExpression": "${params}" + }, + { + "deviceNameFilter": ".*", + "methodFilter": "write", + "valueExpression": "${params}" + } + ] +} diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json b/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json new file mode 100644 index 0000000000..437a6abd95 --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/modbus.json @@ -0,0 +1,248 @@ +{ + "master": { + "slaves": [ + { + "host": "127.0.0.1", + "port": 5021, + "type": "tcp", + "method": "socket", + "timeout": 35, + "byteOrder": "LITTLE", + "wordOrder": "LITTLE", + "retries": true, + "retryOnEmpty": true, + "retryOnInvalid": true, + "pollPeriod": 5000, + "unitId": 1, + "deviceName": "Temp Sensor", + "sendDataOnlyOnChange": true, + "connectAttemptTimeMs": 5000, + "connectAttemptCount": 5, + "waitAfterFailedAttemptsMs": 300000, + "attributes": [ + { + "tag": "string_read", + "type": "string", + "functionCode": 4, + "objectsCount": 4, + "address": 1 + }, + { + "tag": "bits_read", + "type": "bits", + "functionCode": 4, + "objectsCount": 1, + "address": 5 + }, + { + "tag": "8int_read", + "type": "8int", + "functionCode": 4, + "objectsCount": 1, + "address": 6 + }, + { + "tag": "16int_read", + "type": "16int", + "functionCode": 4, + "objectsCount": 1, + "address": 7 + }, + { + "tag": "32int_read_divider", + "type": "32int", + "functionCode": 4, + "objectsCount": 2, + "address": 8, + "divider": 10 + }, + { + "tag": "8int_read_multiplier", + "type": "8int", + "functionCode": 4, + "objectsCount": 1, + "address": 10, + "multiplier": 10 + }, + { + "tag": "32int_read", + "type": "32int", + "functionCode": 4, + "objectsCount": 2, + "address": 11 + }, + { + "tag": "64int_read", + "type": "64int", + "functionCode": 4, + "objectsCount": 4, + "address": 13 + } + ], + "timeseries": [ + { + "tag": "8uint_read", + "type": "8uint", + "functionCode": 4, + "objectsCount": 1, + "address": 17 + }, + { + "tag": "16uint_read", + "type": "16uint", + "functionCode": 4, + "objectsCount": 2, + "address": 18 + }, + { + "tag": "32uint_read", + "type": "32uint", + "functionCode": 4, + "objectsCount": 4, + "address": 20 + }, + { + "tag": "64uint_read", + "type": "64uint", + "functionCode": 4, + "objectsCount": 1, + "address": 24 + }, + { + "tag": "16float_read", + "type": "16float", + "functionCode": 4, + "objectsCount": 1, + "address": 25 + }, + { + "tag": "32float_read", + "type": "32float", + "functionCode": 4, + "objectsCount": 2, + "address": 26 + }, + { + "tag": "64float_read", + "type": "64float", + "functionCode": 4, + "objectsCount": 4, + "address": 28 + } + ], + "attributeUpdates": [ + { + "tag": "shared_attribute_write", + "type": "32int", + "functionCode": 6, + "objectsCount": 2, + "address": 29 + } + ], + "rpc": [ + { + "tag": "setValue", + "type": "bits", + "functionCode": 5, + "objectsCount": 1, + "address": 31 + }, + { + "tag": "getValue", + "type": "bits", + "functionCode": 1, + "objectsCount": 1, + "address": 31 + }, + { + "tag": "setCPUFanSpeed", + "type": "32int", + "functionCode": 16, + "objectsCount": 2, + "address": 33 + }, + { + "tag": "getCPULoad", + "type": "32int", + "functionCode": 4, + "objectsCount": 2, + "address": 35 + } + ] + } + ] + }, + "slave": { + "type": "tcp", + "host": "127.0.0.1", + "port": 5026, + "method": "socket", + "deviceName": "Modbus Slave Example", + "deviceType": "default", + "pollPeriod": 5000, + "sendDataToThingsBoard": false, + "byteOrder": "LITTLE", + "wordOrder": "LITTLE", + "unitId": 0, + "values": { + "holding_registers": [ + { + "attributes": [ + { + "address": 1, + "type": "string", + "tag": "sm", + "objectsCount": 1, + "value": "ON" + } + ], + "timeseries": [ + { + "address": 2, + "type": "int", + "tag": "smm", + "objectsCount": 1, + "value": "12334" + } + ], + "attributeUpdates": [ + { + "tag": "shared_attribute_write", + "type": "32int", + "functionCode": 6, + "objectsCount": 2, + "address": 29, + "value": 1243 + } + ], + "rpc": [ + { + "tag": "setValue", + "type": "bits", + "functionCode": 5, + "objectsCount": 1, + "address": 31, + "value": 22 + } + ] + } + ], + "coils_initializer": [ + { + "attributes": [ + { + "address": 5, + "type": "string", + "tag": "sm", + "objectsCount": 1, + "value": "12" + } + ], + "timeseries": [], + "attributeUpdates": [], + "rpc": [] + } + ] + } + } +} diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/mqtt.json b/ui-ngx/src/assets/metadata/connector-default-configs/mqtt.json new file mode 100644 index 0000000000..c60e1dd956 --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/mqtt.json @@ -0,0 +1,218 @@ +{ + "broker": { + "name": "Default Local Broker", + "host": "127.0.0.1", + "port": 1883, + "clientId": "ThingsBoard_gateway", + "version": 5, + "maxMessageNumberPerWorker": 10, + "maxNumberOfWorkers": 100, + "sendDataOnlyOnChange": false, + "security": { + "type": "anonymous" + } + }, + "dataMapping": [ + { + "topicFilter": "sensor/data", + "subscriptionQos": 1, + "converter": { + "type": "json", + "deviceInfo": { + "deviceNameExpressionSource": "message", + "deviceNameExpression": "${serialNumber}", + "deviceProfileExpressionSource": "message", + "deviceProfileExpression": "${sensorType}" + }, + "sendDataOnlyOnChange": false, + "timeout": 60000, + "attributes": [ + { + "type": "string", + "key": "model", + "value": "${sensorModel}" + }, + { + "type": "string", + "key": "${sensorModel}", + "value": "on" + } + ], + "timeseries": [ + { + "type": "string", + "key": "temperature", + "value": "${temp}" + }, + { + "type": "double", + "key": "humidity", + "value": "${hum}" + }, + { + "type": "string", + "key": "combine", + "value": "${hum}:${temp}" + } + ] + } + }, + { + "topicFilter": "sensor/+/data", + "subscriptionQos": 1, + "converter": { + "type": "json", + "deviceInfo": { + "deviceNameExpressionSource": "topic", + "deviceNameExpression": "(?<=sensor\/)(.*?)(?=\/data)", + "deviceProfileExpressionSource": "constant", + "deviceProfileExpression": "Thermometer" + }, + "sendDataOnlyOnChange": false, + "timeout": 60000, + "attributes": [ + { + "type": "string", + "key": "model", + "value": "${sensorModel}" + } + ], + "timeseries": [ + { + "type": "double", + "key": "temperature", + "value": "${temp}" + }, + { + "type": "string", + "key": "humidity", + "value": "${hum}" + } + ] + } + }, + { + "topicFilter": "sensor/raw_data", + "subscriptionQos": 1, + "converter": { + "type": "bytes", + "deviceInfo": { + "deviceNameExpressionSource": "message", + "deviceNameExpression": "[0:4]", + "deviceProfileExpressionSource": "constant", + "deviceProfileExpression": "default" + }, + "sendDataOnlyOnChange": false, + "timeout": 60000, + "attributes": [ + { + "type": "raw", + "key": "rawData", + "value": "[:]" + } + ], + "timeseries": [ + { + "type": "raw", + "key": "temp", + "value": "[4:]" + } + ] + } + }, + { + "topicFilter": "custom/sensors/+", + "subscriptionQos": 1, + "converter": { + "type": "custom", + "extension": "CustomMqttUplinkConverter", + "cached": true, + "extensionConfig": { + "temperature": 2, + "humidity": 2, + "batteryLevel": 1 + } + } + } + ], + "requestsMapping": { + "connectRequests": [ + { + "topicFilter": "sensor/connect", + "deviceInfo": { + "deviceNameExpressionSource": "message", + "deviceNameExpression": "${serialNumber}", + "deviceProfileExpressionSource": "constant", + "deviceProfileExpression": "Thermometer" + } + }, + { + "topicFilter": "sensor/+/connect", + "deviceInfo": { + "deviceNameExpressionSource": "topic", + "deviceNameExpression": "(?<=sensor\/)(.*?)(?=\/connect)", + "deviceProfileExpressionSource": "constant", + "deviceProfileExpression": "Thermometer" + } + } + ], + "disconnectRequests": [ + { + "topicFilter": "sensor/disconnect", + "deviceInfo": { + "deviceNameExpressionSource": "message", + "deviceNameExpression": "${serialNumber}" + } + }, + { + "topicFilter": "sensor/+/disconnect", + "deviceInfo": { + "deviceNameExpressionSource": "topic", + "deviceNameExpression": "(?<=sensor\/)(.*?)(?=\/connect)" + } + } + ], + "attributeRequests": [ + { + "retain": false, + "topicFilter": "v1/devices/me/attributes/request", + "deviceInfo": { + "deviceNameExpressionSource": "message", + "deviceNameExpression": "${serialNumber}" + }, + "attributeNameExpressionSource": "message", + "attributeNameExpression": "${versionAttribute}, ${pduAttribute}", + "topicExpression": "devices/${deviceName}/attrs", + "valueExpression": "${attributeKey}: ${attributeValue}" + } + ], + "attributeUpdates": [ + { + "retain": true, + "deviceNameFilter": ".*", + "attributeFilter": "firmwareVersion", + "topicExpression": "sensor/${deviceName}/${attributeKey}", + "valueExpression": "{\"${attributeKey}\":\"${attributeValue}\"}" + } + ], + "serverSideRpc": [ + { + "type": "twoWay", + "deviceNameFilter": ".*", + "methodFilter": "echo", + "requestTopicExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", + "responseTopicExpression": "sensor/${deviceName}/response/${methodName}/${requestId}", + "responseTopicQoS": 1, + "responseTimeout": 10000, + "valueExpression": "${params}" + }, + { + "type": "oneWay", + "deviceNameFilter": ".*", + "methodFilter": "no-reply", + "requestTopicExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", + "valueExpression": "${params}" + } + ] + } +} diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/ocpp.json b/ui-ngx/src/assets/metadata/connector-default-configs/ocpp.json new file mode 100644 index 0000000000..35d878836a --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/ocpp.json @@ -0,0 +1,66 @@ +{ + "centralSystem": { + "name": "Central System", + "host": "127.0.0.1", + "port": 9000, + "connection": { + "type": "insecure" + }, + "security": [ + { + "type": "token", + "tokens": [ + "Bearer ACCESS_TOKEN" + ] + }, + { + "type": "basic", + "credentials": [ + { + "username": "admin", + "password": "admin" + } + ] + } + ] + }, + "chargePoints": [ + { + "idRegexpPattern": "bidon/hello/CP_1", + "deviceNameExpression": "${Vendor} ${Model}", + "deviceTypeExpression": "default", + "attributes": [ + { + "messageTypeFilter": "MeterValues,", + "key": "temp1", + "value": "${meter_value[:].sampled_value[:].value}" + }, + { + "messageTypeFilter": "MeterValues,", + "key": "vendorId", + "value": "${connector_id}" + } + ], + "timeseries": [ + { + "messageTypeFilter": "DataTransfer,", + "key": "temp", + "value": "${data.temp}" + } + ], + "attributeUpdates": [ + { + "attributeOnThingsBoard": "shared", + "valueExpression": "{\"${attributeKey}\":\"${attributeValue}\"}" + } + ], + "serverSideRpc": [ + { + "methodRPC": "rpc1", + "withResponse": true, + "valueExpression": "${params}" + } + ] + } + ] +} \ No newline at end of file diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/odbc.json b/ui-ngx/src/assets/metadata/connector-default-configs/odbc.json new file mode 100644 index 0000000000..dc70cdf421 --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/odbc.json @@ -0,0 +1,58 @@ +{ + "connection": { + "str": "Driver={PostgreSQL};Server=localhost;Port=5432;Database=thingsboard;Uid=postgres;Pwd=postgres;", + "attributes": { + "autocommit": true, + "timeout": 0 + }, + "encoding": "utf-8", + "decoding": { + "char": "utf-8", + "wchar": "utf-8", + "metadata": "utf-16le" + }, + "reconnect": true, + "reconnectPeriod": 60 + }, + "pyodbc": { + "pooling": false + }, + "polling": { + "query": "SELECT bool_v, str_v, dbl_v, long_v, entity_id, ts FROM ts_kv WHERE ts > ? ORDER BY ts ASC LIMIT 10", + "period": 10, + "iterator": { + "column": "ts", + "query": "SELECT MIN(ts) - 1 FROM ts_kv", + "persistent": false + } + }, + "mapping": { + "device": { + "type": "postgres", + "name": "'ODBC ' + entity_id" + }, + "sendDataOnlyOnChange": false, + "attributes": "*", + "timeseries": [ + { + "name": "value", + "value": "[i for i in [str_v, long_v, dbl_v,bool_v] if i is not None][0]" + } + ] + }, + "serverSideRpc": { + "enableUnknownRpc": false, + "overrideRpcConfig": true, + "methods": [ + "procedureOne", + { + "name": "procedureTwo", + "args": [ + "One", + 2, + 3.0 + ] + } + ] + } +} \ No newline at end of file diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json b/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json new file mode 100644 index 0000000000..91ee295648 --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/opcua.json @@ -0,0 +1,52 @@ +{ + "server": { + "name": "OPC-UA Default Server", + "url": "localhost:4840/freeopcua/server/", + "timeoutInMillis": 5000, + "scanPeriodInMillis": 5000, + "disableSubscriptions": false, + "subCheckPeriodInMillis": 100, + "showMap": false, + "security": "Basic128Rsa15", + "identity": { + "type": "anonymous" + }, + "mapping": [ + { + "deviceNodePattern": "Root\\.Objects\\.Device1", + "deviceNamePattern": "Device ${Root\\.Objects\\.Device1\\.serialNumber}", + "attributes": [ + { + "key": "temperature °C", + "path": "${ns=2;i=5}" + } + ], + "timeseries": [ + { + "key": "humidity", + "path": "${Root\\.Objects\\.Device1\\.TemperatureAndHumiditySensor\\.Humidity}" + }, + { + "key": "batteryLevel", + "path": "${Battery\\.batteryLevel}" + } + ], + "rpc_methods": [ + { + "method": "multiply", + "arguments": [ + 2, + 4 + ] + } + ], + "attributes_updates": [ + { + "attributeOnThingsBoard": "deviceName", + "attributeOnDevice": "Root\\.Objects\\.Device1\\.serialNumber" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/opcua_asyncio.json b/ui-ngx/src/assets/metadata/connector-default-configs/opcua_asyncio.json new file mode 100644 index 0000000000..91ee295648 --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/opcua_asyncio.json @@ -0,0 +1,52 @@ +{ + "server": { + "name": "OPC-UA Default Server", + "url": "localhost:4840/freeopcua/server/", + "timeoutInMillis": 5000, + "scanPeriodInMillis": 5000, + "disableSubscriptions": false, + "subCheckPeriodInMillis": 100, + "showMap": false, + "security": "Basic128Rsa15", + "identity": { + "type": "anonymous" + }, + "mapping": [ + { + "deviceNodePattern": "Root\\.Objects\\.Device1", + "deviceNamePattern": "Device ${Root\\.Objects\\.Device1\\.serialNumber}", + "attributes": [ + { + "key": "temperature °C", + "path": "${ns=2;i=5}" + } + ], + "timeseries": [ + { + "key": "humidity", + "path": "${Root\\.Objects\\.Device1\\.TemperatureAndHumiditySensor\\.Humidity}" + }, + { + "key": "batteryLevel", + "path": "${Battery\\.batteryLevel}" + } + ], + "rpc_methods": [ + { + "method": "multiply", + "arguments": [ + 2, + 4 + ] + } + ], + "attributes_updates": [ + { + "attributeOnThingsBoard": "deviceName", + "attributeOnDevice": "Root\\.Objects\\.Device1\\.serialNumber" + } + ] + } + ] + } +} \ No newline at end of file diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/request.json b/ui-ngx/src/assets/metadata/connector-default-configs/request.json new file mode 100644 index 0000000000..c5b96ff4cf --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/request.json @@ -0,0 +1,169 @@ +{ + "host": "http://127.0.0.1:5000", + "SSLVerify": true, + "security": { + "type": "basic", + "username": "user", + "password": "password" + }, + "mapping": [ + { + "url": "getdata", + "httpMethod": "GET", + "httpHeaders": { + "ACCEPT": "application/json" + }, + "content": { + "name": "morpheus", + "job": "leader" + }, + "allowRedirects": true, + "timeout": 0.5, + "scanPeriod": 5, + "converter": { + "type": "json", + "deviceNameJsonExpression": "SD8500", + "deviceTypeJsonExpression": "SD", + "attributes": [ + { + "key": "serialNumber", + "type": "string", + "value": "${serial}" + } + ], + "telemetry": [ + { + "key": "Maintainer", + "type": "string", + "value": "${Developer}" + }, + { + "key": "combine", + "type": "string", + "value": "${Developer}:${hum}" + } + ] + } + }, + { + "url": "get_info", + "httpMethod": "GET", + "httpHeaders": { + "ACCEPT": "application/json" + }, + "allowRedirects": true, + "timeout": 0.5, + "scanPeriod": 100, + "converter": { + "type": "custom", + "deviceNameJsonExpression": "SD8500", + "deviceTypeJsonExpression": "SD", + "extension": "CustomRequestUplinkConverter", + "extension-config": [ + { + "key": "Totaliser", + "type": "float", + "fromByte": 0, + "toByte": 4, + "byteorder": "big", + "signed": true, + "multiplier": 1 + }, + { + "key": "Flow", + "type": "int", + "fromByte": 4, + "toByte": 6, + "byteorder": "big", + "signed": true, + "multiplier": 0.01 + }, + { + "key": "Temperature", + "type": "int", + "fromByte": 8, + "toByte": 10, + "byteorder": "big", + "signed": true, + "multiplier": 0.01 + }, + { + "key": "Pressure", + "type": "int", + "fromByte": 12, + "toByte": 14, + "byteorder": "big", + "signed": true, + "multiplier": 0.01 + }, + { + "key": "deviceStatus", + "type": "int", + "byteAddress": 15, + "fromBit": 4, + "toBit": 8, + "byteorder": "big", + "signed": false + }, + { + "key": "OUT2", + "type": "int", + "byteAddress": 15, + "fromBit": 1, + "toBit": 2, + "byteorder": "big" + }, + { + "key": "OUT1", + "type": "int", + "byteAddress": 15, + "fromBit": 0, + "toBit": 1, + "byteorder": "big" + } + ] + } + } + ], + "attributeUpdates": [ + { + "httpMethod": "POST", + "httpHeaders": { + "CONTENT-TYPE": "application/json" + }, + "timeout": 0.5, + "tries": 3, + "allowRedirects": true, + "deviceNameFilter": "SD.*", + "attributeFilter": "send_data", + "requestUrlExpression": "sensor/${deviceName}/${attributeKey}", + "requestValueExpression": "{\"${attributeKey}\":\"${attributeValue}\"}" + } + ], + "serverSideRpc": [ + { + "deviceNameFilter": ".*", + "methodFilter": "echo", + "requestUrlExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", + "responseTimeout": 1, + "httpMethod": "GET", + "requestValueExpression": "${params}", + "responseValueExpression": "${temp}", + "timeout": 0.5, + "tries": 3, + "httpHeaders": { + "Content-Type": "application/json" + } + }, + { + "deviceNameFilter": ".*", + "methodFilter": "no-reply", + "requestUrlExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", + "httpMethod": "POST", + "requestValueExpression": "${params}", + "httpHeaders": { + "Content-Type": "application/json" + } + } + ] +} diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/rest.json b/ui-ngx/src/assets/metadata/connector-default-configs/rest.json new file mode 100644 index 0000000000..f927b8f8b2 --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/rest.json @@ -0,0 +1,190 @@ +{ + "host": "127.0.0.1", + "port": "5000", + "SSL": false, + "security": { + "cert": "~/ssl/cert.pem", + "key": "~/ssl/key.pem" + }, + "mapping": [ + { + "endpoint": "/my_devices", + "HTTPMethods": [ + "POST" + ], + "security": { + "type": "anonymous" + }, + "converter": { + "type": "json", + "deviceNameExpression": "${sensorName}", + "deviceTypeExpression": "${sensorType}", + "attributes": [ + { + "type": "string", + "key": "model", + "value": "${sensorModel}" + }, + { + "type": "string", + "key": "certificateNumber", + "value": "${certificateNumber}" + } + ], + "timeseries": [ + { + "type": "double", + "key": "temperature", + "value": "${temp}" + }, + { + "type": "double", + "key": "humidity", + "value": "${hum}" + } + ] + } + }, + { + "endpoint": "/anon1", + "HTTPMethods": [ + "GET", + "POST" + ], + "security": { + "type": "anonymous" + }, + "converter": { + "type": "json", + "deviceNameExpression": "Device 2", + "deviceTypeExpression": "default", + "attributes": [ + { + "type": "string", + "key": "model", + "value": "Model2" + } + ], + "timeseries": [ + { + "type": "double", + "key": "temperature", + "value": "${temp}" + }, + { + "type": "double", + "key": "humidity", + "value": "${hum}" + } + ] + } + }, + { + "endpoint": "/anon2", + "HTTPMethods": [ + "POST" + ], + "security": { + "type": "anonymous" + }, + "converter": { + "type": "custom", + "deviceNameExpression": "SuperAnonDevice", + "deviceTypeExpression": "default", + "extension": "CustomRestUplinkConverter", + "extension-config": [ + { + "key": "Totaliser", + "datatype": "float", + "fromByte": 0, + "toByte": 4, + "byteorder": "big", + "signed": true, + "multiplier": 1 + } + ] + } + } + ], + "attributeRequests": [ + { + "endpoint": "/sharedAttributes", + "type": "shared", + "HTTPMethods": [ + "POST" + ], + "security": { + "type": "anonymous" + }, + "timeout": 10.0, + "deviceNameExpression": "${deviceName}", + "attributeNameExpression": "${attribute}${attribute1}" + } + ], + "attributeUpdates": [ + { + "HTTPMethod": "POST", + "SSLVerify": false, + "httpHeaders": { + "CONTENT-TYPE": "application/json" + }, + "security": { + "type": "anonymous" + }, + "timeout": 0.5, + "tries": 3, + "allowRedirects": true, + "deviceNameFilter": "SN.*", + "attributeFilter": ".*", + "requestUrlExpression": "http://127.0.0.1:5001/", + "valueExpression": "{\"deviceName\":\"${deviceName}\",\"${attributeKey}\":\"${attributeValue}\"}" + } + ], + "serverSideRpc": [ + { + "deviceNameFilter": ".*", + "methodFilter": "echo", + "requestUrlExpression": "http://127.0.0.1:5001/${deviceName}", + "responseTimeout": 1, + "HTTPMethod": "GET", + "valueExpression": "${params}", + "timeout": 10.0, + "tries": 3, + "httpHeaders": { + "Content-Type": "application/json" + }, + "security": { + "type": "anonymous" + } + }, + { + "deviceNameFilter": "SN.*", + "methodFilter": "post_attributes", + "requestUrlExpression": "http://127.0.0.1:5000/my_devices", + "responseTimeout": 1, + "HTTPMethod": "POST", + "valueExpression": "{\"sensorName\":\"${deviceName}\", \"sensorModel\":\"${params.sensorModel}\", \"certificateNumber\":\"${params.certificateNumber}\", \"temp\":\"${params.temp}\", \"hum\":\"${params.hum}\"}", + "timeout": 10.0, + "tries": 3, + "httpHeaders": { + "Content-Type": "application/json" + }, + "security": { + "type": "anonymous" + } + }, + { + "deviceNameFilter": ".*", + "methodFilter": "no-reply", + "requestUrlExpression": "sensor/${deviceName}/request/${methodName}/${requestId}", + "HTTPMethod": "POST", + "valueExpression": "${params}", + "httpHeaders": { + "Content-Type": "application/json" + }, + "security": { + "type": "anonymous" + } + } + ] +} \ No newline at end of file diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/snmp.json b/ui-ngx/src/assets/metadata/connector-default-configs/snmp.json new file mode 100644 index 0000000000..1d782dc665 --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/snmp.json @@ -0,0 +1,138 @@ +{ + "devices": [ + { + "deviceName": "SNMP router", + "deviceType": "snmp", + "ip": "snmp.live.gambitcommunications.com", + "port": 161, + "pollPeriod": 5000, + "community": "public", + "attributes": [ + { + "key": "ReceivedFromGet", + "method": "get", + "oid": "1.3.6.1.2.1.1.1.0", + "timeout": 6 + }, + { + "key": "ReceivedFromMultiGet", + "method": "multiget", + "oid": [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.1.2.1.1.2.0" + ], + "timeout": 6 + }, + { + "key": "ReceivedFromGetNext", + "method": "getnext", + "oid": "1.3.6.1.2.1.1.1.0", + "timeout": 6 + }, + { + "key": "ReceivedFromMultiWalk", + "method": "multiwalk", + "oid": [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.0.1.2.1" + ] + }, + { + "key": "ReceivedFromBulkWalk", + "method": "bulkwalk", + "oid": [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.1.2.1.1.2.0" + ] + }, + { + "key": "ReceivedFromBulkGet", + "method": "bulkget", + "scalarOid": [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.1.2.1.1.2.0" + ], + "repeatingOid": [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.1.2.1.1.2.0" + ], + "maxListSize": 10 + } + ], + "telemetry": [ + { + "key": "ReceivedFromWalk", + "community": "private", + "method": "walk", + "oid": "1.3.6.1.2.1.1.1.0" + }, + { + "key": "ReceivedFromTable", + "method": "table", + "oid": "1.3.6.1.2.1.1" + } + ], + "attributeUpdateRequests": [ + { + "attributeFilter": "dataToSet", + "method": "set", + "oid": "1.3.6.1.2.1.1.1.0" + }, + { + "attributeFilter": "dataToMultiSet", + "method": "multiset", + "mappings": { + "1.2.3": "10", + "2.3.4": "${attribute}" + } + } + ], + "serverSideRpcRequests": [ + { + "requestFilter": "setData", + "method": "set", + "oid": "1.3.6.1.2.1.1.1.0" + }, + { + "requestFilter": "multiSetData", + "method": "multiset" + }, + { + "requestFilter": "getData", + "method": "get", + "oid": "1.3.6.1.2.1.1.1.0" + }, + { + "requestFilter": "runBulkWalk", + "method": "bulkwalk", + "oid": [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.1.2.1.1.2.0" + ] + } + ] + }, + { + "deviceName": "SNMP router", + "deviceType": "snmp", + "ip": "127.0.0.1", + "pollPeriod": 5000, + "community": "public", + "converter": "CustomSNMPConverter", + "attributes": [ + { + "key": "ReceivedFromGetWithCustomConverter", + "method": "get", + "oid": "1.3.6.1.2.1.1.1.0" + } + ], + "telemetry": [ + { + "key": "ReceivedFromTableWithCustomConverter", + "method": "table", + "oid": "1.3.6.1.2.1.1.1.0" + } + ] + } + ] +} diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/socket.json b/ui-ngx/src/assets/metadata/connector-default-configs/socket.json new file mode 100644 index 0000000000..47fd59af4a --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/socket.json @@ -0,0 +1,59 @@ +{ + "type": "TCP", + "address": "127.0.0.1", + "port": 50000, + "bufferSize": 1024, + "devices": [ + { + "address": "127.0.0.1:50001", + "deviceName": "Device Example", + "deviceType": "default", + "encoding": "utf-8", + "telemetry": [ + { + "key": "temp", + "byteFrom": 0, + "byteTo": -1 + }, + { + "key": "hum", + "byteFrom": 0, + "byteTo": 2 + } + ], + "attributes": [ + { + "key": "name", + "byteFrom": 0, + "byteTo": -1 + }, + { + "key": "num", + "byteFrom": 2, + "byteTo": 4 + } + ], + "attributeRequests": [ + { + "type": "shared", + "requestExpression": "${[0:3]==atr}", + "attributeNameExpression": "[3:]" + } + ], + "attributeUpdates": [ + { + "encoding": "utf-16", + "attributeOnThingsBoard": "sharedName" + } + ], + "serverSideRpc": [ + { + "methodRPC": "rpcMethod1", + "withResponse": true, + "methodProcessing": "write", + "encoding": "utf-8" + } + ] + } + ] +} diff --git a/ui-ngx/src/assets/metadata/connector-default-configs/xmpp.json b/ui-ngx/src/assets/metadata/connector-default-configs/xmpp.json new file mode 100644 index 0000000000..e66504363e --- /dev/null +++ b/ui-ngx/src/assets/metadata/connector-default-configs/xmpp.json @@ -0,0 +1,53 @@ +{ + "server": { + "jid": "gateway@localhost", + "password": "password", + "host": "localhost", + "port": 5222, + "use_ssl": false, + "disable_starttls": false, + "force_starttls": true, + "timeout": 10000, + "plugins": [ + "xep_0030", + "xep_0323", + "xep_0325" + ] + }, + "devices": [ + { + "jid": "device@localhost/TMP_1101", + "deviceNameExpression": "${serialNumber}", + "deviceTypeExpression": "default", + "attributes": [ + { + "key": "temperature", + "value": "${temp}" + } + ], + "timeseries": [ + { + "key": "humidity", + "value": "${hum}" + }, + { + "key": "combination", + "value": "${temp}:${hum}" + } + ], + "attributeUpdates": [ + { + "attributeOnThingsBoard": "shared", + "valueExpression": "{\"${attributeKey}\":\"${attributeValue}\"}" + } + ], + "serverSideRpc": [ + { + "methodRPC": "rpc1", + "withResponse": true, + "valueExpression": "${params}" + } + ] + } + ] +} diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index 1f9ca93c88..f38d459b97 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -62,6 +62,9 @@ } &.no-padding { padding: 0; + &.padding-top { + padding-top: 16px; + } } &.stroked { box-shadow: none; @@ -231,6 +234,12 @@ } .fixed-title-width { min-width: 200px; + + &-230 { + min-width: 230px; + } + } + [class^="fixed-title-width"] { @media #{$mat-xs} { min-width: fit-content; } @@ -254,12 +263,21 @@ display: flex; flex: 1; gap: 8px; + &.no-flex { + flex: none; + } &.row { flex-direction: row; } + &.row-reverse { + flex-direction: row-reverse; + } &.column { flex-direction: column; } + &.center { + justify-content: center; + } &.flex-start { justify-content: flex-start; } @@ -269,6 +287,9 @@ &.space-between { justify-content: space-between; } + &.center { + justify-content: center; + } &.align-center { align-items: center; } @@ -278,6 +299,12 @@ &.fill-width { width: 100%; } + &.fill-height { + height: 100%; + } + &.shrink { + flex: 0; + } } .tb-form-panel, .tb-form-row { @@ -505,10 +532,20 @@ gap: 12px; padding-bottom: 12px; + &.no-padding { + padding: 0; + } + &.no-gap { + gap: 0; + } + .tb-form-table-body { display: flex; flex-direction: column; gap: 12px; + &.no-gap { + gap: 0; + } } .tb-prompt { diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index 8ca9fe4e37..4d5ae09a15 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -856,6 +856,9 @@ mat-icon { .mat-icon { color: rgba(0, 0, 0, .54); } + &.tb-pointer { + cursor: pointer; + } } .mdc-data-table__row:last-child .mdc-data-table__cell {